diff --git a/Documentation/TESTING.md b/Documentation/TESTING.md new file mode 100644 index 0000000..1254aea --- /dev/null +++ b/Documentation/TESTING.md @@ -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%) diff --git a/TESTING_QUICKSTART.md b/TESTING_QUICKSTART.md new file mode 100644 index 0000000..d09802f --- /dev/null +++ b/TESTING_QUICKSTART.md @@ -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` diff --git a/abschnitte/management/commands/test_clear_diagram_cache.py b/abschnitte/management/commands/test_clear_diagram_cache.py new file mode 100644 index 0000000..4b1347b --- /dev/null +++ b/abschnitte/management/commands/test_clear_diagram_cache.py @@ -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)) diff --git a/abschnitte/tests.py b/abschnitte/tests.py index 7ce503c..93a0813 100644 --- a/abschnitte/tests.py +++ b/abschnitte/tests.py @@ -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("bold", html) + self.assertIn("italic", 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("
| Name | ', html) + self.assertIn('Age | ', html) + self.assertIn('Alice | ', html) + self.assertIn('30 | ', 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('Name | ', html) + self.assertIn('Age | ', html) + self.assertIn('Alice | ', 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('Name | ', html) + self.assertIn('Alice | ', 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('Alice | ', html) + self.assertIn('', html) + self.assertIn(' | 25 | ', 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('First | ', html) + self.assertIn('Middle | ', html) + self.assertIn('Last | ', html) + self.assertIn('John | ', html) + self.assertIn('Q | ', html) + self.assertIn('Doe | ', 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("
|---|