Feature: POST-based diagram generation with local caching #2
259
Documentation/TESTING.md
Normal file
259
Documentation/TESTING.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# Running Tests
|
||||||
|
|
||||||
|
This document describes how to run the test suite for the vgui-cicd project.
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
The project includes comprehensive unit tests for the diagram caching functionality:
|
||||||
|
|
||||||
|
```
|
||||||
|
abschnitte/
|
||||||
|
├── tests.py # Tests for rendering functions
|
||||||
|
├── management/
|
||||||
|
│ └── commands/
|
||||||
|
│ └── test_clear_diagram_cache.py # Tests for management command
|
||||||
|
|
||||||
|
diagramm_proxy/
|
||||||
|
└── test_diagram_cache.py # Tests for caching module
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running All Tests
|
||||||
|
|
||||||
|
To run the entire test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Specific Test Modules
|
||||||
|
|
||||||
|
Run tests for a specific app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test abschnitte rendering
|
||||||
|
python manage.py test abschnitte
|
||||||
|
|
||||||
|
# Test diagram caching
|
||||||
|
python manage.py test diagramm_proxy
|
||||||
|
|
||||||
|
# Test management commands
|
||||||
|
python manage.py test abschnitte.management.commands
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Individual Test Cases
|
||||||
|
|
||||||
|
Run a specific test case:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test diagram cache functionality
|
||||||
|
python manage.py test diagramm_proxy.test_diagram_cache.DiagramCacheTestCase
|
||||||
|
|
||||||
|
# Test rendering functions
|
||||||
|
python manage.py test abschnitte.tests.RenderTextabschnitteTestCase
|
||||||
|
|
||||||
|
# Test management command
|
||||||
|
python manage.py test abschnitte.management.commands.test_clear_diagram_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Individual Tests
|
||||||
|
|
||||||
|
Run a single test method:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test abschnitte.tests.RenderTextabschnitteTestCase.test_render_diagram_success
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
To generate a coverage report, install coverage.py:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests with coverage
|
||||||
|
coverage run --source='.' manage.py test
|
||||||
|
|
||||||
|
# Generate coverage report
|
||||||
|
coverage report
|
||||||
|
|
||||||
|
# Generate HTML coverage report
|
||||||
|
coverage html
|
||||||
|
# Open htmlcov/index.html in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Options
|
||||||
|
|
||||||
|
### Verbose Output
|
||||||
|
|
||||||
|
Get more detailed output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test --verbosity=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keep Test Database
|
||||||
|
|
||||||
|
Keep the test database after tests complete (useful for debugging):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test --keepdb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fail Fast
|
||||||
|
|
||||||
|
Stop after first test failure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test --failfast
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parallel Testing
|
||||||
|
|
||||||
|
Run tests in parallel (faster for large test suites):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test --parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the Tests Cover
|
||||||
|
|
||||||
|
### Diagram Caching Tests (`diagramm_proxy/test_diagram_cache.py`)
|
||||||
|
|
||||||
|
- ✅ Hash computation and consistency
|
||||||
|
- ✅ Cache path generation
|
||||||
|
- ✅ Cache miss (diagram generation)
|
||||||
|
- ✅ Cache hit (using cached diagrams)
|
||||||
|
- ✅ HTTP error handling
|
||||||
|
- ✅ Cache clearing (all and by type)
|
||||||
|
- ✅ Unicode content handling
|
||||||
|
- ✅ Timeout configuration
|
||||||
|
- ✅ Full lifecycle integration tests
|
||||||
|
|
||||||
|
### Rendering Tests (`abschnitte/tests.py`)
|
||||||
|
|
||||||
|
- ✅ Text rendering with markdown
|
||||||
|
- ✅ Unordered and ordered lists
|
||||||
|
- ✅ Table rendering
|
||||||
|
- ✅ Diagram rendering (success and error)
|
||||||
|
- ✅ Diagram with custom options
|
||||||
|
- ✅ Code blocks
|
||||||
|
- ✅ Edge cases (empty content, missing types)
|
||||||
|
- ✅ Multiple sections
|
||||||
|
- ✅ Mixed content integration tests
|
||||||
|
|
||||||
|
### Management Command Tests (`abschnitte/management/commands/test_clear_diagram_cache.py`)
|
||||||
|
|
||||||
|
- ✅ Clearing all cache
|
||||||
|
- ✅ Clearing by specific type
|
||||||
|
- ✅ Clearing empty cache
|
||||||
|
- ✅ Command help text
|
||||||
|
- ✅ Full workflow integration
|
||||||
|
|
||||||
|
## Continuous Integration
|
||||||
|
|
||||||
|
These tests are designed to run in CI/CD pipelines. Example GitHub Actions workflow:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
python manage.py test --verbosity=2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Failed Tests
|
||||||
|
|
||||||
|
If tests fail, you can:
|
||||||
|
|
||||||
|
1. **Run with verbose output** to see detailed error messages:
|
||||||
|
```bash
|
||||||
|
python manage.py test --verbosity=2
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run specific failing test** to isolate the issue:
|
||||||
|
```bash
|
||||||
|
python manage.py test path.to.TestCase.test_method
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use pdb for debugging**:
|
||||||
|
```python
|
||||||
|
import pdb; pdb.set_trace() # Add to test code
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check test database**:
|
||||||
|
```bash
|
||||||
|
python manage.py test --keepdb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Requirements
|
||||||
|
|
||||||
|
The tests use:
|
||||||
|
- Django's built-in `TestCase` class
|
||||||
|
- Python's `unittest.mock` for mocking external dependencies
|
||||||
|
- `@override_settings` for temporary setting changes
|
||||||
|
- `tempfile` for creating temporary directories
|
||||||
|
|
||||||
|
No additional testing libraries are required beyond what's in `requirements.txt`.
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
When adding new features, follow these patterns:
|
||||||
|
|
||||||
|
1. **Test file location**: Place tests in the same app as the code being tested
|
||||||
|
2. **Test class naming**: Use descriptive names ending in `TestCase`
|
||||||
|
3. **Test method naming**: Start with `test_` and describe what's being tested
|
||||||
|
4. **Use mocks**: Mock external dependencies (HTTP calls, file systems when needed)
|
||||||
|
5. **Clean up**: Use `setUp()` and `tearDown()` or temporary directories
|
||||||
|
6. **Test edge cases**: Include tests for error conditions and edge cases
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.test import TestCase
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
class MyFeatureTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
self.test_data = "example"
|
||||||
|
|
||||||
|
def test_basic_functionality(self):
|
||||||
|
"""Test that the feature works correctly."""
|
||||||
|
result = my_function(self.test_data)
|
||||||
|
self.assertEqual(result, expected_value)
|
||||||
|
|
||||||
|
@patch('my_app.module.external_call')
|
||||||
|
def test_with_mock(self, mock_call):
|
||||||
|
"""Test with mocked external dependency."""
|
||||||
|
mock_call.return_value = "mocked"
|
||||||
|
result = my_function(self.test_data)
|
||||||
|
mock_call.assert_called_once()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Best Practices
|
||||||
|
|
||||||
|
- Write tests before or alongside code (TDD approach)
|
||||||
|
- Each test should test one thing
|
||||||
|
- Tests should be independent (no shared state)
|
||||||
|
- Use descriptive test names
|
||||||
|
- Mock external dependencies (HTTP, filesystem, etc.)
|
||||||
|
- Test both success and failure cases
|
||||||
|
- Aim for high code coverage (>80%)
|
||||||
99
TESTING_QUICKSTART.md
Normal file
99
TESTING_QUICKSTART.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Quick Test Guide
|
||||||
|
|
||||||
|
Quick reference for running tests in the vgui-cicd project.
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
python manage.py test
|
||||||
|
|
||||||
|
# Or use the convenient runner
|
||||||
|
python run_tests.py all
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Common Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `python run_tests.py all` | Run all tests with verbose output |
|
||||||
|
| `python run_tests.py cache` | Run diagram cache tests only |
|
||||||
|
| `python run_tests.py render` | Run rendering tests only |
|
||||||
|
| `python run_tests.py commands` | Run management command tests |
|
||||||
|
| `python run_tests.py coverage` | Run tests with coverage report |
|
||||||
|
| `python run_tests.py fast` | Run with fail-fast (stops at first failure) |
|
||||||
|
|
||||||
|
## 🎯 Run Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test a specific module
|
||||||
|
python manage.py test diagramm_proxy
|
||||||
|
|
||||||
|
# Test a specific test case
|
||||||
|
python manage.py test abschnitte.tests.RenderTextabschnitteTestCase
|
||||||
|
|
||||||
|
# Test a specific method
|
||||||
|
python manage.py test abschnitte.tests.RenderTextabschnitteTestCase.test_render_diagram_success
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Coverage Report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate coverage report
|
||||||
|
python run_tests.py coverage
|
||||||
|
|
||||||
|
# Or manually
|
||||||
|
pip install coverage
|
||||||
|
coverage run --source='.' manage.py test
|
||||||
|
coverage report
|
||||||
|
coverage html # Creates htmlcov/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ What's Tested
|
||||||
|
|
||||||
|
### Diagram Caching (`diagramm_proxy/test_diagram_cache.py`)
|
||||||
|
- Hash computation ✓
|
||||||
|
- Cache hit/miss ✓
|
||||||
|
- HTTP errors ✓
|
||||||
|
- Cache clearing ✓
|
||||||
|
- Unicode support ✓
|
||||||
|
|
||||||
|
### Rendering (`abschnitte/tests.py`)
|
||||||
|
- Markdown to HTML ✓
|
||||||
|
- Lists (ordered/unordered) ✓
|
||||||
|
- Tables ✓
|
||||||
|
- Diagrams ✓
|
||||||
|
- Code blocks ✓
|
||||||
|
|
||||||
|
### Management Commands (`abschnitte/management/commands/test_clear_diagram_cache.py`)
|
||||||
|
- Clear all cache ✓
|
||||||
|
- Clear by type ✓
|
||||||
|
- Help text ✓
|
||||||
|
|
||||||
|
## 🐛 Debugging Failed Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with more detail
|
||||||
|
python manage.py test --verbosity=2
|
||||||
|
|
||||||
|
# Stop at first failure
|
||||||
|
python manage.py test --failfast
|
||||||
|
|
||||||
|
# Keep test database
|
||||||
|
python manage.py test --keepdb
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 More Information
|
||||||
|
|
||||||
|
See `Documentation/TESTING.md` for complete documentation.
|
||||||
|
|
||||||
|
## 🎓 Test Statistics
|
||||||
|
|
||||||
|
- **Total Test Files**: 3
|
||||||
|
- **Total Test Cases**: 29+
|
||||||
|
- **Lines of Test Code**: 790+
|
||||||
|
- **Coverage Target**: >80%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Need help?** Check the full testing guide in `Documentation/TESTING.md`
|
||||||
164
abschnitte/management/commands/test_clear_diagram_cache.py
Normal file
164
abschnitte/management/commands/test_clear_diagram_cache.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from io import StringIO
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
|
||||||
|
class ClearDiagramCacheCommandTestCase(TestCase):
|
||||||
|
"""Test cases for clear_diagram_cache management command."""
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
def test_clear_all_cache(self):
|
||||||
|
"""Test clearing all cached diagrams."""
|
||||||
|
# Create test cache files
|
||||||
|
test_files = [
|
||||||
|
'diagram_cache/plantuml/hash1.svg',
|
||||||
|
'diagram_cache/plantuml/hash2.svg',
|
||||||
|
'diagram_cache/mermaid/hash3.svg',
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in test_files:
|
||||||
|
full_path = default_storage.path(path)
|
||||||
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
|
default_storage.save(path, ContentFile(b'test diagram'))
|
||||||
|
|
||||||
|
# Verify files exist
|
||||||
|
for path in test_files:
|
||||||
|
self.assertTrue(default_storage.exists(path))
|
||||||
|
|
||||||
|
# Run command
|
||||||
|
out = StringIO()
|
||||||
|
call_command('clear_diagram_cache', stdout=out)
|
||||||
|
|
||||||
|
# Verify files are deleted
|
||||||
|
for path in test_files:
|
||||||
|
self.assertFalse(default_storage.exists(path))
|
||||||
|
|
||||||
|
# Verify success message
|
||||||
|
output = out.getvalue()
|
||||||
|
self.assertIn('Clearing all diagram caches', output)
|
||||||
|
self.assertIn('Cache cleared successfully', output)
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
def test_clear_cache_by_type(self):
|
||||||
|
"""Test clearing cache for specific diagram type."""
|
||||||
|
# Create test cache files
|
||||||
|
plantuml_files = [
|
||||||
|
'diagram_cache/plantuml/hash1.svg',
|
||||||
|
'diagram_cache/plantuml/hash2.svg',
|
||||||
|
]
|
||||||
|
mermaid_files = [
|
||||||
|
'diagram_cache/mermaid/hash3.svg',
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in plantuml_files + mermaid_files:
|
||||||
|
full_path = default_storage.path(path)
|
||||||
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
|
default_storage.save(path, ContentFile(b'test diagram'))
|
||||||
|
|
||||||
|
# Run command for plantuml only
|
||||||
|
out = StringIO()
|
||||||
|
call_command('clear_diagram_cache', type='plantuml', stdout=out)
|
||||||
|
|
||||||
|
# Verify only plantuml files are deleted
|
||||||
|
for path in plantuml_files:
|
||||||
|
self.assertFalse(default_storage.exists(path))
|
||||||
|
|
||||||
|
for path in mermaid_files:
|
||||||
|
self.assertTrue(default_storage.exists(path))
|
||||||
|
|
||||||
|
# Verify output message
|
||||||
|
output = out.getvalue()
|
||||||
|
self.assertIn('Clearing cache for plantuml', output)
|
||||||
|
self.assertIn('Cache cleared successfully', output)
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
def test_clear_empty_cache(self):
|
||||||
|
"""Test clearing cache when no files exist."""
|
||||||
|
# Don't create any files
|
||||||
|
|
||||||
|
# Should not raise error
|
||||||
|
out = StringIO()
|
||||||
|
call_command('clear_diagram_cache', stdout=out)
|
||||||
|
|
||||||
|
# Should still show success
|
||||||
|
output = out.getvalue()
|
||||||
|
self.assertIn('Cache cleared successfully', output)
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
@patch('diagramm_proxy.diagram_cache.clear_cache')
|
||||||
|
def test_command_calls_clear_cache_function(self, mock_clear):
|
||||||
|
"""Test that command properly calls the clear_cache function."""
|
||||||
|
# Call without type
|
||||||
|
call_command('clear_diagram_cache')
|
||||||
|
mock_clear.assert_called_once_with(None)
|
||||||
|
|
||||||
|
# Call with type
|
||||||
|
mock_clear.reset_mock()
|
||||||
|
call_command('clear_diagram_cache', type='plantuml')
|
||||||
|
mock_clear.assert_called_once_with('plantuml')
|
||||||
|
|
||||||
|
def test_command_help_text(self):
|
||||||
|
"""Test that command has proper help text."""
|
||||||
|
out = StringIO()
|
||||||
|
call_command('clear_diagram_cache', '--help', stdout=out)
|
||||||
|
output = out.getvalue()
|
||||||
|
|
||||||
|
self.assertIn('Clear cached diagrams', output)
|
||||||
|
self.assertIn('--type', output)
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementCommandIntegrationTestCase(TestCase):
|
||||||
|
"""Integration tests for management command with real filesystem."""
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
def test_full_command_workflow(self):
|
||||||
|
"""Test complete workflow: create cache, clear specific type, clear all."""
|
||||||
|
# Create mixed cache files
|
||||||
|
files = {
|
||||||
|
'plantuml': [
|
||||||
|
'diagram_cache/plantuml/abc123.svg',
|
||||||
|
'diagram_cache/plantuml/def456.svg',
|
||||||
|
],
|
||||||
|
'mermaid': [
|
||||||
|
'diagram_cache/mermaid/ghi789.svg',
|
||||||
|
],
|
||||||
|
'graphviz': [
|
||||||
|
'diagram_cache/graphviz/jkl012.svg',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create all files
|
||||||
|
for file_list in files.values():
|
||||||
|
for path in file_list:
|
||||||
|
full_path = default_storage.path(path)
|
||||||
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
|
default_storage.save(path, ContentFile(b'diagram content'))
|
||||||
|
|
||||||
|
# Verify all exist
|
||||||
|
for file_list in files.values():
|
||||||
|
for path in file_list:
|
||||||
|
self.assertTrue(default_storage.exists(path))
|
||||||
|
|
||||||
|
# Clear plantuml
|
||||||
|
call_command('clear_diagram_cache', type='plantuml')
|
||||||
|
|
||||||
|
# Verify plantuml deleted, others remain
|
||||||
|
for path in files['plantuml']:
|
||||||
|
self.assertFalse(default_storage.exists(path))
|
||||||
|
for path in files['mermaid']:
|
||||||
|
self.assertTrue(default_storage.exists(path))
|
||||||
|
for path in files['graphviz']:
|
||||||
|
self.assertTrue(default_storage.exists(path))
|
||||||
|
|
||||||
|
# Clear all remaining
|
||||||
|
call_command('clear_diagram_cache')
|
||||||
|
|
||||||
|
# Verify all deleted
|
||||||
|
for file_list in files.values():
|
||||||
|
for path in file_list:
|
||||||
|
self.assertFalse(default_storage.exists(path))
|
||||||
@@ -1,3 +1,377 @@
|
|||||||
from django.test import TestCase
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from abschnitte.models import AbschnittTyp, Textabschnitt
|
||||||
|
from abschnitte.utils import render_textabschnitte, md_table_to_html
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
|
class MockTextabschnitt:
|
||||||
|
"""Mock object for Textabschnitt (since it's abstract)."""
|
||||||
|
def __init__(self, abschnitttyp, inhalt):
|
||||||
|
self.abschnitttyp = abschnitttyp
|
||||||
|
self.inhalt = inhalt
|
||||||
|
|
||||||
|
|
||||||
|
class RenderTextabschnitteTestCase(TestCase):
|
||||||
|
"""Test cases for render_textabschnitte function."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
# Create mock AbschnittTyp objects
|
||||||
|
self.typ_text = Mock()
|
||||||
|
self.typ_text.abschnitttyp = "text"
|
||||||
|
|
||||||
|
self.typ_liste_ungeordnet = Mock()
|
||||||
|
self.typ_liste_ungeordnet.abschnitttyp = "liste ungeordnet"
|
||||||
|
|
||||||
|
self.typ_liste_geordnet = Mock()
|
||||||
|
self.typ_liste_geordnet.abschnitttyp = "liste geordnet"
|
||||||
|
|
||||||
|
self.typ_tabelle = Mock()
|
||||||
|
self.typ_tabelle.abschnitttyp = "tabelle"
|
||||||
|
|
||||||
|
self.typ_diagramm = Mock()
|
||||||
|
self.typ_diagramm.abschnitttyp = "diagramm"
|
||||||
|
|
||||||
|
self.typ_code = Mock()
|
||||||
|
self.typ_code.abschnitttyp = "code"
|
||||||
|
|
||||||
|
def test_render_basic_text(self):
|
||||||
|
"""Test rendering basic text content."""
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_text,
|
||||||
|
inhalt="This is **bold** text with *italic*."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
typ, html = result[0]
|
||||||
|
self.assertEqual(typ, "text")
|
||||||
|
self.assertIn("<strong>bold</strong>", html)
|
||||||
|
self.assertIn("<em>italic</em>", html)
|
||||||
|
|
||||||
|
def test_render_unordered_list(self):
|
||||||
|
"""Test rendering unordered list."""
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_liste_ungeordnet,
|
||||||
|
inhalt="Item 1\nItem 2\nItem 3"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
typ, html = result[0]
|
||||||
|
self.assertEqual(typ, "liste ungeordnet")
|
||||||
|
self.assertIn("<ul>", html)
|
||||||
|
self.assertIn("<li>Item 1</li>", html)
|
||||||
|
self.assertIn("<li>Item 2</li>", html)
|
||||||
|
self.assertIn("<li>Item 3</li>", html)
|
||||||
|
|
||||||
|
def test_render_ordered_list(self):
|
||||||
|
"""Test rendering ordered list."""
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_liste_geordnet,
|
||||||
|
inhalt="First item\nSecond item\nThird item"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
typ, html = result[0]
|
||||||
|
self.assertEqual(typ, "liste geordnet")
|
||||||
|
self.assertIn("<ol>", html)
|
||||||
|
self.assertIn("<li>First item</li>", html)
|
||||||
|
|
||||||
|
def test_render_table(self):
|
||||||
|
"""Test rendering markdown table."""
|
||||||
|
table_content = """| Header 1 | Header 2 |
|
||||||
|
|----------|----------|
|
||||||
|
| Cell 1 | Cell 2 |
|
||||||
|
| Cell 3 | Cell 4 |"""
|
||||||
|
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_tabelle,
|
||||||
|
inhalt=table_content
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
typ, html = result[0]
|
||||||
|
self.assertEqual(typ, "tabelle")
|
||||||
|
self.assertIn("<table", html)
|
||||||
|
self.assertIn("Header 1", html)
|
||||||
|
self.assertIn("Cell 1", html)
|
||||||
|
self.assertIn("table-bordered", html)
|
||||||
|
|
||||||
|
@override_settings(MEDIA_URL='/media/')
|
||||||
|
@patch('abschnitte.utils.get_cached_diagram')
|
||||||
|
def test_render_diagram_success(self, mock_get_cached):
|
||||||
|
"""Test rendering diagram with successful cache."""
|
||||||
|
mock_get_cached.return_value = 'diagram_cache/plantuml/abc123.svg'
|
||||||
|
|
||||||
|
diagram_content = """plantuml
|
||||||
|
@startuml
|
||||||
|
A -> B
|
||||||
|
@enduml"""
|
||||||
|
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_diagramm,
|
||||||
|
inhalt=diagram_content
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
typ, html = result[0]
|
||||||
|
self.assertEqual(typ, "diagramm")
|
||||||
|
|
||||||
|
# Verify diagram cache was called correctly
|
||||||
|
mock_get_cached.assert_called_once_with(
|
||||||
|
'plantuml',
|
||||||
|
'@startuml\nA -> B\n@enduml'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify HTML contains correct image tag
|
||||||
|
self.assertIn('<img', html)
|
||||||
|
self.assertIn('/media/diagram_cache/plantuml/abc123.svg', html)
|
||||||
|
self.assertIn('width="100%"', html)
|
||||||
|
|
||||||
|
@patch('abschnitte.utils.get_cached_diagram')
|
||||||
|
def test_render_diagram_with_options(self, mock_get_cached):
|
||||||
|
"""Test rendering diagram with custom options."""
|
||||||
|
mock_get_cached.return_value = 'diagram_cache/mermaid/def456.svg'
|
||||||
|
|
||||||
|
diagram_content = """mermaid
|
||||||
|
option:width="50%" height="300px"
|
||||||
|
graph TD
|
||||||
|
A --> B"""
|
||||||
|
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_diagramm,
|
||||||
|
inhalt=diagram_content
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
typ, html = result[0]
|
||||||
|
|
||||||
|
# Verify custom options are used
|
||||||
|
self.assertIn('width="50%"', html)
|
||||||
|
self.assertIn('height="300px"', html)
|
||||||
|
|
||||||
|
# Verify diagram content doesn't include option line
|
||||||
|
call_args = mock_get_cached.call_args[0]
|
||||||
|
self.assertNotIn('option:', call_args[1])
|
||||||
|
|
||||||
|
@patch('abschnitte.utils.get_cached_diagram')
|
||||||
|
def test_render_diagram_error(self, mock_get_cached):
|
||||||
|
"""Test rendering diagram when caching fails."""
|
||||||
|
mock_get_cached.side_effect = Exception("Kroki server unavailable")
|
||||||
|
|
||||||
|
diagram_content = """plantuml
|
||||||
|
@startuml
|
||||||
|
A -> B
|
||||||
|
@enduml"""
|
||||||
|
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_diagramm,
|
||||||
|
inhalt=diagram_content
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
typ, html = result[0]
|
||||||
|
|
||||||
|
# Should show error message
|
||||||
|
self.assertIn('Error generating diagram', html)
|
||||||
|
self.assertIn('Kroki server unavailable', html)
|
||||||
|
self.assertIn('text-danger', html)
|
||||||
|
|
||||||
|
def test_render_code(self):
|
||||||
|
"""Test rendering code block."""
|
||||||
|
code_content = """def hello():
|
||||||
|
print("Hello, World!")"""
|
||||||
|
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_code,
|
||||||
|
inhalt=code_content
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
typ, html = result[0]
|
||||||
|
self.assertEqual(typ, "code")
|
||||||
|
self.assertIn("<pre>", html)
|
||||||
|
self.assertIn("<code>", html)
|
||||||
|
self.assertIn("def hello():", html)
|
||||||
|
|
||||||
|
def test_render_empty_content(self):
|
||||||
|
"""Test rendering with empty or None content."""
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=self.typ_text,
|
||||||
|
inhalt=None
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
typ, html = result[0]
|
||||||
|
self.assertEqual(typ, "text")
|
||||||
|
# Should not crash, should return empty or minimal HTML
|
||||||
|
|
||||||
|
def test_render_multiple_abschnitte(self):
|
||||||
|
"""Test rendering multiple sections."""
|
||||||
|
abschnitte = [
|
||||||
|
MockTextabschnitt(self.typ_text, "First paragraph"),
|
||||||
|
MockTextabschnitt(self.typ_liste_ungeordnet, "Item 1\nItem 2"),
|
||||||
|
MockTextabschnitt(self.typ_text, "Second paragraph"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = render_textabschnitte(abschnitte)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 3)
|
||||||
|
self.assertEqual(result[0][0], "text")
|
||||||
|
self.assertEqual(result[1][0], "liste ungeordnet")
|
||||||
|
self.assertEqual(result[2][0], "text")
|
||||||
|
|
||||||
|
def test_render_no_abschnitttyp(self):
|
||||||
|
"""Test rendering when abschnitttyp is None."""
|
||||||
|
abschnitt = MockTextabschnitt(
|
||||||
|
abschnitttyp=None,
|
||||||
|
inhalt="Some content"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_textabschnitte([abschnitt])
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
typ, html = result[0]
|
||||||
|
self.assertEqual(typ, "")
|
||||||
|
# Should still render as markdown
|
||||||
|
self.assertIn("Some content", html)
|
||||||
|
|
||||||
|
|
||||||
|
class MdTableToHtmlTestCase(TestCase):
|
||||||
|
"""Test cases for md_table_to_html function."""
|
||||||
|
|
||||||
|
def test_basic_table(self):
|
||||||
|
"""Test converting basic markdown table to HTML."""
|
||||||
|
md = """| Name | Age |
|
||||||
|
|------|-----|
|
||||||
|
| Alice | 30 |
|
||||||
|
| Bob | 25 |"""
|
||||||
|
|
||||||
|
html = md_table_to_html(md)
|
||||||
|
|
||||||
|
self.assertIn('<table', html)
|
||||||
|
self.assertIn('table-bordered', html)
|
||||||
|
self.assertIn('table-hover', html)
|
||||||
|
self.assertIn('<thead>', html)
|
||||||
|
self.assertIn('<tbody>', html)
|
||||||
|
self.assertIn('<th>Name</th>', html)
|
||||||
|
self.assertIn('<th>Age</th>', html)
|
||||||
|
self.assertIn('<td>Alice</td>', html)
|
||||||
|
self.assertIn('<td>30</td>', html)
|
||||||
|
|
||||||
|
def test_table_with_spaces(self):
|
||||||
|
"""Test table with various spacing."""
|
||||||
|
md = """| Name | Age |
|
||||||
|
|--------|-------|
|
||||||
|
| Alice | 30 |
|
||||||
|
| Bob | 25 |"""
|
||||||
|
|
||||||
|
html = md_table_to_html(md)
|
||||||
|
|
||||||
|
# Spaces should be trimmed
|
||||||
|
self.assertIn('<th>Name</th>', html)
|
||||||
|
self.assertIn('<th>Age</th>', html)
|
||||||
|
self.assertIn('<td>Alice</td>', html)
|
||||||
|
|
||||||
|
def test_table_no_outer_pipes(self):
|
||||||
|
"""Test table without outer pipe characters."""
|
||||||
|
md = """Name | Age
|
||||||
|
-----|-----
|
||||||
|
Alice | 30
|
||||||
|
Bob | 25"""
|
||||||
|
|
||||||
|
html = md_table_to_html(md)
|
||||||
|
|
||||||
|
self.assertIn('<th>Name</th>', html)
|
||||||
|
self.assertIn('<td>Alice</td>', html)
|
||||||
|
|
||||||
|
def test_table_insufficient_rows(self):
|
||||||
|
"""Test error handling for malformed table."""
|
||||||
|
md = """| Name |
|
||||||
|
|------|"""
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError) as context:
|
||||||
|
md_table_to_html(md)
|
||||||
|
|
||||||
|
self.assertIn("at least header + separator", str(context.exception))
|
||||||
|
|
||||||
|
def test_table_with_empty_cells(self):
|
||||||
|
"""Test table with empty cells."""
|
||||||
|
md = """| Name | Age |
|
||||||
|
|------|-----|
|
||||||
|
| Alice | |
|
||||||
|
| | 25 |"""
|
||||||
|
|
||||||
|
html = md_table_to_html(md)
|
||||||
|
|
||||||
|
self.assertIn('<td>Alice</td>', html)
|
||||||
|
self.assertIn('<td></td>', html)
|
||||||
|
self.assertIn('<td>25</td>', html)
|
||||||
|
|
||||||
|
def test_table_three_columns(self):
|
||||||
|
"""Test table with more columns."""
|
||||||
|
md = """| First | Middle | Last |
|
||||||
|
|-------|--------|------|
|
||||||
|
| John | Q | Doe |"""
|
||||||
|
|
||||||
|
html = md_table_to_html(md)
|
||||||
|
|
||||||
|
self.assertIn('<th>First</th>', html)
|
||||||
|
self.assertIn('<th>Middle</th>', html)
|
||||||
|
self.assertIn('<th>Last</th>', html)
|
||||||
|
self.assertIn('<td>John</td>', html)
|
||||||
|
self.assertIn('<td>Q</td>', html)
|
||||||
|
self.assertIn('<td>Doe</td>', html)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderIntegrationTestCase(TestCase):
|
||||||
|
"""Integration tests for rendering pipeline."""
|
||||||
|
|
||||||
|
@patch('abschnitte.utils.get_cached_diagram')
|
||||||
|
def test_mixed_content_rendering(self, mock_get_cached):
|
||||||
|
"""Test rendering a mix of different content types."""
|
||||||
|
mock_get_cached.return_value = 'diagram_cache/test.svg'
|
||||||
|
|
||||||
|
# Create various types of content
|
||||||
|
typ_text = Mock(abschnitttyp="text")
|
||||||
|
typ_liste = Mock(abschnitttyp="liste ungeordnet")
|
||||||
|
typ_diagram = Mock(abschnitttyp="diagramm")
|
||||||
|
|
||||||
|
abschnitte = [
|
||||||
|
MockTextabschnitt(typ_text, "Introduction paragraph"),
|
||||||
|
MockTextabschnitt(typ_liste, "Point 1\nPoint 2\nPoint 3"),
|
||||||
|
MockTextabschnitt(typ_diagram, "plantuml\n@startuml\nA -> B\n@enduml"),
|
||||||
|
MockTextabschnitt(typ_text, "Conclusion"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = render_textabschnitte(abschnitte)
|
||||||
|
|
||||||
|
# Should have 4 results
|
||||||
|
self.assertEqual(len(result), 4)
|
||||||
|
|
||||||
|
# Verify each type was rendered correctly
|
||||||
|
self.assertEqual(result[0][0], "text")
|
||||||
|
self.assertIn("Introduction", result[0][1])
|
||||||
|
|
||||||
|
self.assertEqual(result[1][0], "liste ungeordnet")
|
||||||
|
self.assertIn("<ul>", result[1][1])
|
||||||
|
|
||||||
|
self.assertEqual(result[2][0], "diagramm")
|
||||||
|
self.assertIn("<img", result[2][1])
|
||||||
|
|
||||||
|
self.assertEqual(result[3][0], "text")
|
||||||
|
self.assertIn("Conclusion", result[3][1])
|
||||||
|
|||||||
267
diagramm_proxy/test_diagram_cache.py
Normal file
267
diagramm_proxy/test_diagram_cache.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch, Mock, MagicMock
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from diagramm_proxy.diagram_cache import (
|
||||||
|
get_cache_path,
|
||||||
|
compute_hash,
|
||||||
|
get_cached_diagram,
|
||||||
|
clear_cache,
|
||||||
|
KROKI_UPSTREAM
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramCacheTestCase(TestCase):
|
||||||
|
"""Test cases for diagram caching functionality."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
self.test_diagram_content = """
|
||||||
|
@startuml
|
||||||
|
Alice -> Bob: Hello
|
||||||
|
Bob -> Alice: Hi
|
||||||
|
@enduml
|
||||||
|
"""
|
||||||
|
self.diagram_type = "plantuml"
|
||||||
|
self.temp_media_root = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Clean up after tests."""
|
||||||
|
# Clean up temporary files
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(self.temp_media_root):
|
||||||
|
shutil.rmtree(self.temp_media_root)
|
||||||
|
|
||||||
|
def test_compute_hash(self):
|
||||||
|
"""Test that hash computation is consistent."""
|
||||||
|
content1 = "test content"
|
||||||
|
content2 = "test content"
|
||||||
|
content3 = "different content"
|
||||||
|
|
||||||
|
hash1 = compute_hash(content1)
|
||||||
|
hash2 = compute_hash(content2)
|
||||||
|
hash3 = compute_hash(content3)
|
||||||
|
|
||||||
|
# Same content should produce same hash
|
||||||
|
self.assertEqual(hash1, hash2)
|
||||||
|
|
||||||
|
# Different content should produce different hash
|
||||||
|
self.assertNotEqual(hash1, hash3)
|
||||||
|
|
||||||
|
# Hash should be valid SHA256 (64 hex characters)
|
||||||
|
self.assertEqual(len(hash1), 64)
|
||||||
|
self.assertTrue(all(c in '0123456789abcdef' for c in hash1))
|
||||||
|
|
||||||
|
def test_get_cache_path(self):
|
||||||
|
"""Test cache path generation."""
|
||||||
|
content_hash = "abc123def456"
|
||||||
|
diagram_type = "mermaid"
|
||||||
|
|
||||||
|
path = get_cache_path(diagram_type, content_hash)
|
||||||
|
|
||||||
|
# Path should contain diagram type and hash
|
||||||
|
self.assertIn("diagram_cache", path)
|
||||||
|
self.assertIn(diagram_type, path)
|
||||||
|
self.assertIn(content_hash, path)
|
||||||
|
self.assertTrue(path.endswith(".svg"))
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
@patch('diagramm_proxy.diagram_cache.requests.post')
|
||||||
|
def test_get_cached_diagram_cache_miss(self, mock_post):
|
||||||
|
"""Test diagram generation on cache miss."""
|
||||||
|
# Mock successful Kroki response
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.content = b'<svg>test diagram</svg>'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
# Call function
|
||||||
|
result = get_cached_diagram(self.diagram_type, self.test_diagram_content)
|
||||||
|
|
||||||
|
# Verify POST was called with correct parameters
|
||||||
|
expected_url = f"{KROKI_UPSTREAM}/{self.diagram_type}/svg"
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_args = mock_post.call_args
|
||||||
|
self.assertEqual(call_args[0][0], expected_url)
|
||||||
|
self.assertEqual(call_args[1]['data'], self.test_diagram_content.encode('utf-8'))
|
||||||
|
self.assertEqual(call_args[1]['headers']['Content-Type'], 'text/plain')
|
||||||
|
|
||||||
|
# Verify result is a valid path
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertIn('diagram_cache', result)
|
||||||
|
|
||||||
|
# Verify file was cached
|
||||||
|
self.assertTrue(default_storage.exists(result))
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
def test_get_cached_diagram_cache_hit(self):
|
||||||
|
"""Test that cached diagrams are retrieved without HTTP call."""
|
||||||
|
# Pre-populate cache
|
||||||
|
content_hash = compute_hash(self.test_diagram_content)
|
||||||
|
cache_path = get_cache_path(self.diagram_type, content_hash)
|
||||||
|
|
||||||
|
# Create cache directory and file
|
||||||
|
full_path = default_storage.path(cache_path)
|
||||||
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
|
default_storage.save(cache_path, ContentFile(b'<svg>cached diagram</svg>'))
|
||||||
|
|
||||||
|
# Mock requests.post to ensure it's NOT called
|
||||||
|
with patch('diagramm_proxy.diagram_cache.requests.post') as mock_post:
|
||||||
|
result = get_cached_diagram(self.diagram_type, self.test_diagram_content)
|
||||||
|
|
||||||
|
# Verify POST was NOT called (cache hit)
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
# Verify correct path returned
|
||||||
|
self.assertEqual(result, cache_path)
|
||||||
|
|
||||||
|
# Verify file exists
|
||||||
|
self.assertTrue(default_storage.exists(result))
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
@patch('diagramm_proxy.diagram_cache.requests.post')
|
||||||
|
def test_get_cached_diagram_http_error(self, mock_post):
|
||||||
|
"""Test error handling when Kroki server fails."""
|
||||||
|
# Mock failed HTTP response
|
||||||
|
mock_post.side_effect = Exception("Connection failed")
|
||||||
|
|
||||||
|
# Should raise exception
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
get_cached_diagram(self.diagram_type, self.test_diagram_content)
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
def test_clear_cache_all(self):
|
||||||
|
"""Test clearing all cached diagrams."""
|
||||||
|
# Create some test cache files
|
||||||
|
test_files = [
|
||||||
|
('diagram_cache/plantuml/hash1.svg', b'diagram1'),
|
||||||
|
('diagram_cache/plantuml/hash2.svg', b'diagram2'),
|
||||||
|
('diagram_cache/mermaid/hash3.svg', b'diagram3'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for path, content in test_files:
|
||||||
|
full_path = default_storage.path(path)
|
||||||
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
|
default_storage.save(path, ContentFile(content))
|
||||||
|
|
||||||
|
# Verify files exist
|
||||||
|
for path, _ in test_files:
|
||||||
|
self.assertTrue(default_storage.exists(path))
|
||||||
|
|
||||||
|
# Clear all cache
|
||||||
|
clear_cache()
|
||||||
|
|
||||||
|
# Verify files are deleted
|
||||||
|
for path, _ in test_files:
|
||||||
|
self.assertFalse(default_storage.exists(path))
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
def test_clear_cache_by_type(self):
|
||||||
|
"""Test clearing cache for specific diagram type."""
|
||||||
|
# Create test cache files
|
||||||
|
plantuml_file = 'diagram_cache/plantuml/hash1.svg'
|
||||||
|
mermaid_file = 'diagram_cache/mermaid/hash2.svg'
|
||||||
|
|
||||||
|
for path in [plantuml_file, mermaid_file]:
|
||||||
|
full_path = default_storage.path(path)
|
||||||
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
|
default_storage.save(path, ContentFile(b'test'))
|
||||||
|
|
||||||
|
# Clear only plantuml cache
|
||||||
|
clear_cache('plantuml')
|
||||||
|
|
||||||
|
# Verify plantuml file is deleted but mermaid remains
|
||||||
|
self.assertFalse(default_storage.exists(plantuml_file))
|
||||||
|
self.assertTrue(default_storage.exists(mermaid_file))
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
@patch('diagramm_proxy.diagram_cache.requests.post')
|
||||||
|
def test_same_content_different_type(self, mock_post):
|
||||||
|
"""Test that same content but different type creates different cache entries."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.content = b'<svg>diagram</svg>'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
# Generate for two different types
|
||||||
|
path1 = get_cached_diagram('plantuml', self.test_diagram_content)
|
||||||
|
path2 = get_cached_diagram('mermaid', self.test_diagram_content)
|
||||||
|
|
||||||
|
# Paths should be different
|
||||||
|
self.assertNotEqual(path1, path2)
|
||||||
|
self.assertIn('plantuml', path1)
|
||||||
|
self.assertIn('mermaid', path2)
|
||||||
|
|
||||||
|
# Both should exist
|
||||||
|
self.assertTrue(default_storage.exists(path1))
|
||||||
|
self.assertTrue(default_storage.exists(path2))
|
||||||
|
|
||||||
|
def test_compute_hash_unicode(self):
|
||||||
|
"""Test hash computation with unicode characters."""
|
||||||
|
content_with_unicode = "Test with émojis 🎨 and ümlauts"
|
||||||
|
|
||||||
|
# Should not raise exception
|
||||||
|
result = compute_hash(content_with_unicode)
|
||||||
|
|
||||||
|
# Should produce valid hash
|
||||||
|
self.assertEqual(len(result), 64)
|
||||||
|
self.assertTrue(all(c in '0123456789abcdef' for c in result))
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
@patch('diagramm_proxy.diagram_cache.requests.post')
|
||||||
|
def test_timeout_configuration(self, mock_post):
|
||||||
|
"""Test that POST request includes timeout."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.content = b'<svg>test</svg>'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
get_cached_diagram(self.diagram_type, self.test_diagram_content)
|
||||||
|
|
||||||
|
# Verify timeout parameter was passed
|
||||||
|
call_kwargs = mock_post.call_args[1]
|
||||||
|
self.assertIn('timeout', call_kwargs)
|
||||||
|
self.assertEqual(call_kwargs['timeout'], 30)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramCacheIntegrationTestCase(TestCase):
|
||||||
|
"""Integration tests for diagram caching with file system."""
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
|
||||||
|
@patch('diagramm_proxy.diagram_cache.requests.post')
|
||||||
|
def test_full_cache_lifecycle(self, mock_post):
|
||||||
|
"""Test complete lifecycle: cache miss, cache hit, clear."""
|
||||||
|
diagram_content = "@startuml\nA -> B\n@enduml"
|
||||||
|
diagram_type = "plantuml"
|
||||||
|
|
||||||
|
# Mock Kroki response
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.content = b'<svg>lifecycle test</svg>'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
# First call - cache miss
|
||||||
|
path1 = get_cached_diagram(diagram_type, diagram_content)
|
||||||
|
self.assertEqual(mock_post.call_count, 1)
|
||||||
|
self.assertTrue(default_storage.exists(path1))
|
||||||
|
|
||||||
|
# Second call - cache hit
|
||||||
|
path2 = get_cached_diagram(diagram_type, diagram_content)
|
||||||
|
self.assertEqual(mock_post.call_count, 1) # Still 1, not called again
|
||||||
|
self.assertEqual(path1, path2)
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
clear_cache()
|
||||||
|
self.assertFalse(default_storage.exists(path1))
|
||||||
|
|
||||||
|
# Third call - cache miss again
|
||||||
|
path3 = get_cached_diagram(diagram_type, diagram_content)
|
||||||
|
self.assertEqual(mock_post.call_count, 2) # Called again
|
||||||
|
self.assertTrue(default_storage.exists(path3))
|
||||||
60
pytest.ini
Normal file
60
pytest.ini
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
[tool:pytest]
|
||||||
|
# Pytest configuration for vgui-cicd project
|
||||||
|
# To use pytest instead of Django's test runner:
|
||||||
|
# pip install pytest pytest-django
|
||||||
|
# pytest
|
||||||
|
|
||||||
|
DJANGO_SETTINGS_MODULE = VorgabenUI.settings
|
||||||
|
python_files = tests.py test_*.py *_tests.py
|
||||||
|
python_classes = *TestCase *Test
|
||||||
|
python_functions = test_*
|
||||||
|
|
||||||
|
# Test discovery patterns
|
||||||
|
testpaths =
|
||||||
|
abschnitte
|
||||||
|
diagramm_proxy
|
||||||
|
standards
|
||||||
|
stichworte
|
||||||
|
referenzen
|
||||||
|
rollen
|
||||||
|
pages
|
||||||
|
|
||||||
|
# Output options
|
||||||
|
addopts =
|
||||||
|
--verbose
|
||||||
|
--strict-markers
|
||||||
|
--tb=short
|
||||||
|
--reuse-db
|
||||||
|
|
||||||
|
# Coverage options (when using pytest-cov)
|
||||||
|
# Uncomment to enable:
|
||||||
|
# --cov=.
|
||||||
|
# --cov-report=html
|
||||||
|
# --cov-report=term-missing
|
||||||
|
|
||||||
|
# Markers for organizing tests
|
||||||
|
markers =
|
||||||
|
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||||
|
integration: marks tests as integration tests
|
||||||
|
unit: marks tests as unit tests
|
||||||
|
cache: marks tests related to caching
|
||||||
|
render: marks tests related to rendering
|
||||||
|
commands: marks tests related to management commands
|
||||||
|
|
||||||
|
# Ignore paths
|
||||||
|
norecursedirs =
|
||||||
|
.git
|
||||||
|
.tox
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.egg
|
||||||
|
__pycache__
|
||||||
|
static
|
||||||
|
staticfiles
|
||||||
|
media
|
||||||
|
data
|
||||||
|
venv
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Django-specific settings
|
||||||
|
django_find_project = false
|
||||||
108
run_tests.py
Normal file
108
run_tests.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Test runner script for vgui-cicd project.
|
||||||
|
Provides convenient shortcuts for running different test suites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run tests based on command line arguments."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'VorgabenUI.settings')
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
command = sys.argv[1]
|
||||||
|
|
||||||
|
if command == 'all':
|
||||||
|
# Run all tests
|
||||||
|
execute_from_command_line(['manage.py', 'test', '--verbosity=2'])
|
||||||
|
|
||||||
|
elif command == 'cache':
|
||||||
|
# Run only cache tests
|
||||||
|
execute_from_command_line(['manage.py', 'test', 'diagramm_proxy', '--verbosity=2'])
|
||||||
|
|
||||||
|
elif command == 'render':
|
||||||
|
# Run only rendering tests
|
||||||
|
execute_from_command_line(['manage.py', 'test', 'abschnitte.tests', '--verbosity=2'])
|
||||||
|
|
||||||
|
elif command == 'commands':
|
||||||
|
# Run only management command tests
|
||||||
|
execute_from_command_line(['manage.py', 'test', 'abschnitte.management.commands', '--verbosity=2'])
|
||||||
|
|
||||||
|
elif command == 'coverage':
|
||||||
|
# Run tests with coverage
|
||||||
|
try:
|
||||||
|
import coverage
|
||||||
|
cov = coverage.Coverage(source=['.'])
|
||||||
|
cov.start()
|
||||||
|
|
||||||
|
execute_from_command_line(['manage.py', 'test', '--verbosity=2'])
|
||||||
|
|
||||||
|
cov.stop()
|
||||||
|
cov.save()
|
||||||
|
|
||||||
|
print('\n' + '='*70)
|
||||||
|
print('COVERAGE REPORT')
|
||||||
|
print('='*70)
|
||||||
|
cov.report()
|
||||||
|
|
||||||
|
print('\nHTML coverage report generated in htmlcov/')
|
||||||
|
cov.html_report()
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print('ERROR: coverage.py not installed.')
|
||||||
|
print('Install with: pip install coverage')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif command == 'fast':
|
||||||
|
# Run tests with failfast
|
||||||
|
execute_from_command_line(['manage.py', 'test', '--failfast', '--verbosity=2'])
|
||||||
|
|
||||||
|
elif command == 'help':
|
||||||
|
print_help()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Pass through to Django test runner
|
||||||
|
execute_from_command_line(['manage.py', 'test'] + sys.argv[1:])
|
||||||
|
|
||||||
|
else:
|
||||||
|
print_help()
|
||||||
|
|
||||||
|
|
||||||
|
def print_help():
|
||||||
|
"""Print help message."""
|
||||||
|
help_text = """
|
||||||
|
Usage: python run_tests.py [command]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
all Run all tests
|
||||||
|
cache Run diagram cache tests only
|
||||||
|
render Run rendering tests only
|
||||||
|
commands Run management command tests only
|
||||||
|
coverage Run tests with coverage report
|
||||||
|
fast Run tests with fail-fast option
|
||||||
|
help Show this help message
|
||||||
|
|
||||||
|
Advanced:
|
||||||
|
You can also pass standard Django test arguments:
|
||||||
|
python run_tests.py <app_label>
|
||||||
|
python run_tests.py <app_label.TestCase>
|
||||||
|
python run_tests.py <app_label.TestCase.test_method>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python run_tests.py all
|
||||||
|
python run_tests.py cache
|
||||||
|
python run_tests.py coverage
|
||||||
|
python run_tests.py diagramm_proxy.test_diagram_cache.DiagramCacheTestCase
|
||||||
|
python run_tests.py abschnitte.tests.RenderTextabschnitteTestCase.test_render_diagram_success
|
||||||
|
|
||||||
|
For more options, see Documentation/TESTING.md
|
||||||
|
"""
|
||||||
|
print(help_text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user