From b179ccaca399642969ed1b4f02cb3c4337c27c17 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:38:04 +0100 Subject: [PATCH 1/3] test(automation): add unit tests for critical automation modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive test coverage for previously untested modules: - sync_to_postgres.py: 39 tests (0% → 60% coverage) - parse_timestamp, parse_bullet_points, convert_datetimes_to_strings - parse_spec_markdown, parse_metadata_yaml, parse_library_metadata_yaml - scan_plot_directory with various directory structures - plot_generator.py: 22 tests (0% → 33% coverage) - extract_and_validate_code with markdown extraction and syntax validation - retry_with_backoff with rate limit and connection error handling - backfill_review_metadata.py: 22 tests (0% → 50% coverage) - parse_ai_review_comment with various PR comment formats - parse_criteria_checklist with category and item parsing - update_metadata_file with dry run support - migrate_metadata_format.py: 21 tests (0% → 87% coverage) - migrate_specification_yaml and migrate_library_metadata - extract_title_from_header with various formats - migrate_plot integration tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/unit/automation/generators/__init__.py | 1 + .../generators/test_plot_generator.py | 326 +++++++++++ .../scripts/test_backfill_review_metadata.py | 405 +++++++++++++ .../scripts/test_migrate_metadata_format.py | 428 ++++++++++++++ .../scripts/test_sync_to_postgres.py | 554 ++++++++++++++++++ 5 files changed, 1714 insertions(+) create mode 100644 tests/unit/automation/generators/__init__.py create mode 100644 tests/unit/automation/generators/test_plot_generator.py create mode 100644 tests/unit/automation/scripts/test_backfill_review_metadata.py create mode 100644 tests/unit/automation/scripts/test_migrate_metadata_format.py create mode 100644 tests/unit/automation/scripts/test_sync_to_postgres.py diff --git a/tests/unit/automation/generators/__init__.py b/tests/unit/automation/generators/__init__.py new file mode 100644 index 0000000000..6af56f1928 --- /dev/null +++ b/tests/unit/automation/generators/__init__.py @@ -0,0 +1 @@ +"""Tests for automation.generators module.""" diff --git a/tests/unit/automation/generators/test_plot_generator.py b/tests/unit/automation/generators/test_plot_generator.py new file mode 100644 index 0000000000..cc77f2a71f --- /dev/null +++ b/tests/unit/automation/generators/test_plot_generator.py @@ -0,0 +1,326 @@ +"""Tests for automation.generators.plot_generator module.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from automation.generators.plot_generator import extract_and_validate_code, retry_with_backoff + + +class TestExtractAndValidateCode: + """Tests for extract_and_validate_code function.""" + + def test_extract_plain_code(self): + response = """import matplotlib.pyplot as plt +plt.plot([1, 2, 3]) +plt.savefig('plot.png')""" + + result = extract_and_validate_code(response) + + assert "import matplotlib" in result + assert "plt.plot" in result + assert "plt.savefig" in result + + def test_extract_from_markdown_python(self): + response = """Here is the implementation: + +```python +import matplotlib.pyplot as plt +import numpy as np + +np.random.seed(42) +x = np.random.randn(100) +plt.scatter(x, x * 0.5) +plt.savefig('plot.png') +``` + +This creates a simple scatter plot.""" + + result = extract_and_validate_code(response) + + assert "import matplotlib" in result + assert "import numpy" in result + assert "np.random.seed(42)" in result + assert "Here is the implementation" not in result + assert "This creates" not in result + + def test_extract_from_generic_markdown(self): + response = """``` +import numpy as np +x = np.array([1, 2, 3]) +print(x) +```""" + + result = extract_and_validate_code(response) + + assert "import numpy" in result + assert "np.array" in result + + def test_extract_code_with_multiple_blocks(self): + """Should extract from first python block.""" + response = """Here's an example: + +```python +import matplotlib.pyplot as plt +plt.plot([1, 2, 3]) +``` + +And here's another: + +```python +print("second block") +``` +""" + + result = extract_and_validate_code(response) + + assert "import matplotlib" in result + assert "plt.plot" in result + # Should only get first block + assert "second block" not in result + + def test_empty_code_raises_value_error(self): + with pytest.raises(ValueError, match="No code could be extracted"): + extract_and_validate_code("") + + def test_whitespace_only_raises_value_error(self): + with pytest.raises(ValueError, match="No code could be extracted"): + extract_and_validate_code(" \n\n ") + + def test_empty_code_block_raises_value_error(self): + response = """```python +```""" + + with pytest.raises(ValueError, match="No code could be extracted"): + extract_and_validate_code(response) + + def test_syntax_error_raises_value_error(self): + response = """```python +def broken( + print("missing closing paren" +```""" + + with pytest.raises(ValueError, match="syntax errors"): + extract_and_validate_code(response) + + def test_indentation_error_raises(self): + response = """```python +def foo(): +print("bad indent") +```""" + + with pytest.raises(ValueError, match="syntax errors"): + extract_and_validate_code(response) + + def test_valid_complex_code(self): + response = """```python +import matplotlib.pyplot as plt +import numpy as np +from typing import Optional + +def create_plot(title: Optional[str] = None) -> None: + np.random.seed(42) + x = np.random.randn(100) + y = x * 0.8 + np.random.randn(100) * 0.5 + + fig, ax = plt.subplots(figsize=(16, 9)) + ax.scatter(x, y, alpha=0.7) + + if title: + ax.set_title(title) + + plt.savefig('plot.png', dpi=300) + +if __name__ == '__main__': + create_plot('Scatter Plot') +```""" + + result = extract_and_validate_code(response) + + assert "np.random.seed(42)" in result + assert "figsize=(16, 9)" in result + assert "def create_plot" in result + assert "Optional[str]" in result + + def test_code_with_comments_and_docstrings(self): + response = '''```python +"""Module docstring.""" + +import matplotlib.pyplot as plt + +# Create a simple plot +def plot_data(): + """Create and save a plot.""" + plt.plot([1, 2, 3]) # inline comment + plt.savefig("output.png") +```''' + + result = extract_and_validate_code(response) + + assert '"""Module docstring."""' in result + assert "# Create a simple plot" in result + assert "# inline comment" in result + + def test_code_with_f_strings(self): + response = """```python +name = "test" +value = 42 +print(f"Name: {name}, Value: {value}") +```""" + + result = extract_and_validate_code(response) + + assert 'f"Name: {name}' in result + + def test_preserves_newlines_in_code(self): + response = """```python +import matplotlib.pyplot as plt + + +def func1(): + pass + + +def func2(): + pass +```""" + + result = extract_and_validate_code(response) + + # Should preserve blank lines + assert "\n\n" in result + + +class TestRetryWithBackoff: + """Tests for retry_with_backoff function.""" + + def test_success_on_first_try(self): + func = MagicMock(return_value="success") + + result = retry_with_backoff(func, max_retries=3) + + assert result == "success" + assert func.call_count == 1 + + def test_retry_on_rate_limit_error(self): + from anthropic import RateLimitError + + mock_response = MagicMock() + mock_response.status_code = 429 + + func = MagicMock( + side_effect=[RateLimitError(message="rate limited", response=mock_response, body={}), "success"] + ) + + with patch("time.sleep"): # Skip actual sleep + result = retry_with_backoff(func, max_retries=3, initial_delay=0.01) + + assert result == "success" + assert func.call_count == 2 + + def test_retry_on_connection_error(self): + from anthropic import APIConnectionError + + mock_request = MagicMock() + + func = MagicMock( + side_effect=[ + APIConnectionError(message="connection failed", request=mock_request), + APIConnectionError(message="connection failed again", request=mock_request), + "success", + ] + ) + + with patch("time.sleep"): + result = retry_with_backoff(func, max_retries=3, initial_delay=0.01) + + assert result == "success" + assert func.call_count == 3 + + def test_max_retries_exceeded_raises(self): + from anthropic import RateLimitError + + mock_response = MagicMock() + mock_response.status_code = 429 + + func = MagicMock(side_effect=RateLimitError(message="rate limited", response=mock_response, body={})) + + with patch("time.sleep"): + with pytest.raises(RateLimitError): + retry_with_backoff(func, max_retries=2, initial_delay=0.01) + + # Initial attempt + 2 retries = 3 calls + assert func.call_count == 3 + + def test_no_retry_on_generic_api_error(self): + """API errors (non-rate-limit, non-connection) should not retry.""" + from anthropic import APIError + + mock_request = MagicMock() + + func = MagicMock(side_effect=APIError(message="bad request", request=mock_request, body={})) + + with pytest.raises(APIError, match="bad request"): + retry_with_backoff(func, max_retries=3) + + # Should not retry + assert func.call_count == 1 + + def test_exponential_backoff_delays(self): + from anthropic import RateLimitError + + mock_response = MagicMock() + mock_response.status_code = 429 + + func = MagicMock( + side_effect=[ + RateLimitError(message="rate limited", response=mock_response, body={}), + RateLimitError(message="rate limited", response=mock_response, body={}), + "success", + ] + ) + + sleep_calls = [] + with patch("time.sleep", side_effect=lambda x: sleep_calls.append(x)): + result = retry_with_backoff(func, max_retries=3, initial_delay=1.0, backoff_factor=2.0) + + assert result == "success" + # First retry: 1.0s, Second retry: 2.0s (1.0 * 2.0) + assert sleep_calls == [1.0, 2.0] + + def test_returns_result_type(self): + """Test that return type matches function return type.""" + func = MagicMock(return_value={"key": "value", "count": 42}) + + result = retry_with_backoff(func) + + assert result == {"key": "value", "count": 42} + assert isinstance(result, dict) + + def test_custom_max_retries(self): + from anthropic import APIConnectionError + + mock_request = MagicMock() + + func = MagicMock(side_effect=APIConnectionError(message="connection failed", request=mock_request)) + + with patch("time.sleep"): + with pytest.raises(APIConnectionError): + retry_with_backoff(func, max_retries=5, initial_delay=0.01) + + # Initial attempt + 5 retries = 6 calls + assert func.call_count == 6 + + def test_zero_retries(self): + from anthropic import RateLimitError + + mock_response = MagicMock() + mock_response.status_code = 429 + + func = MagicMock(side_effect=RateLimitError(message="rate limited", response=mock_response, body={})) + + with pytest.raises(RateLimitError): + retry_with_backoff(func, max_retries=0) + + # Only initial attempt + assert func.call_count == 1 diff --git a/tests/unit/automation/scripts/test_backfill_review_metadata.py b/tests/unit/automation/scripts/test_backfill_review_metadata.py new file mode 100644 index 0000000000..07c2c9c32f --- /dev/null +++ b/tests/unit/automation/scripts/test_backfill_review_metadata.py @@ -0,0 +1,405 @@ +"""Tests for automation.scripts.backfill_review_metadata module.""" + + +import pytest + +from automation.scripts.backfill_review_metadata import parse_ai_review_comment, parse_criteria_checklist + + +class TestParseAiReviewComment: + """Tests for parse_ai_review_comment function.""" + + @pytest.fixture + def complete_review_comment(self): + return """## AI Review + +### Image Description +> The plot shows a scatter plot with 100 data points displaying +> a positive correlation. Points are rendered in blue with 70% +> opacity. Axes are clearly labeled and include units. + +### Strengths +- Clean code structure with proper imports +- Good use of alpha for overlapping points +- Proper axis labels with units +- Appropriate figure size + +### Weaknesses +- Grid could be more subtle +- Consider adding a trend line +- Title could be more descriptive + +### Verdict: APPROVED + +**Quality Score: 92/100** +""" + + def test_parse_complete_review(self, complete_review_comment): + result = parse_ai_review_comment(complete_review_comment) + + assert result is not None + assert "scatter plot with 100 data points" in result["image_description"] + assert "positive correlation" in result["image_description"] + assert result["verdict"] == "APPROVED" + assert len(result["strengths"]) == 4 + assert "Clean code structure" in result["strengths"][0] + assert len(result["weaknesses"]) == 3 + assert "Grid could be more subtle" in result["weaknesses"][0] + + def test_parse_rejected_review(self): + comment = """## AI Review + +### Image Description +> The plot has significant rendering issues. +> Text is overlapping and unreadable. + +### Strengths +- Uses correct library imports + +### Weaknesses +- Major rendering problem with overlapping elements +- Missing axis labels +- Figure size too small +- No title + +### Verdict: REJECTED + +**Quality Score: 45/100** +""" + result = parse_ai_review_comment(comment) + + assert result is not None + assert result["verdict"] == "REJECTED" + assert len(result["strengths"]) == 1 + assert len(result["weaknesses"]) == 4 + + def test_parse_verdict_case_insensitive(self): + comment = """## AI Review + +### Image Description +> Test plot. + +### Verdict: approved +""" + result = parse_ai_review_comment(comment) + + assert result["verdict"] == "APPROVED" + + def test_non_review_comment_returns_none(self): + comment = "This is just a regular comment without AI Review header." + + result = parse_ai_review_comment(comment) + + assert result is None + + def test_partial_review_comment_returns_none(self): + comment = "## Not AI Review\n\nSome other content." + + result = parse_ai_review_comment(comment) + + assert result is None + + def test_parse_multiline_image_description(self): + comment = """## AI Review + +### Image Description +> Line 1 of the description providing context. +> Line 2 continues with more details about the plot. +> Line 3 describes specific visual elements. +> Line 4 concludes the description. + +### Verdict: APPROVED +""" + result = parse_ai_review_comment(comment) + + assert result is not None + desc = result["image_description"] + assert "Line 1" in desc + assert "Line 2" in desc + assert "Line 3" in desc + assert "Line 4" in desc + # Should not have leading > characters + assert not desc.startswith(">") + + def test_parse_empty_strengths_weaknesses(self): + comment = """## AI Review + +### Image Description +> Simple plot description. + +### Strengths + +### Weaknesses + +### Verdict: APPROVED +""" + result = parse_ai_review_comment(comment) + + assert result is not None + assert result["strengths"] == [] + assert result["weaknesses"] == [] + + def test_parse_asterisk_bullets(self): + comment = """## AI Review + +### Image Description +> Test. + +### Strengths +* First strength +* Second strength + +### Weaknesses +* First weakness + +### Verdict: APPROVED +""" + result = parse_ai_review_comment(comment) + + assert result is not None + assert len(result["strengths"]) == 2 + assert "First strength" in result["strengths"] + assert "Second strength" in result["strengths"] + + def test_parse_missing_sections(self): + """Test that missing optional sections are handled gracefully.""" + comment = """## AI Review + +### Verdict: APPROVED +""" + result = parse_ai_review_comment(comment) + + assert result is not None + assert result["verdict"] == "APPROVED" + assert result["image_description"] is None + assert result["strengths"] == [] + assert result["weaknesses"] == [] + + +class TestParseCriteriaChecklist: + """Tests for parse_criteria_checklist function.""" + + @pytest.fixture + def complete_checklist(self): + return """ +**Visual Quality (36/40 pts)** +- [x] VQ-01: Text Legibility (10/10) - All text is readable at full size +- [x] VQ-02: No Overlap (8/8) - Elements are properly spaced +- [ ] VQ-03: Color Contrast (8/10) - Could use higher contrast +- [x] VQ-04: Resolution (10/10) - High quality output at 300 DPI + +**Spec Compliance (23/25 pts)** +- [x] SC-01: Data Requirements (15/15) - All required data columns shown +- [ ] SC-02: Visual Style (8/10) - Minor style deviations + +**Data Quality (18/20 pts)** +- [x] DQ-01: Accuracy (10/10) - Data represented correctly +- [ ] DQ-02: Completeness (8/10) - Some edge cases not shown + +**Code Quality (10/10 pts)** +- [x] CQ-01: Clean Code (5/5) - Well structured and readable +- [x] CQ-02: Documentation (5/5) - Good docstring and comments + +**Library Features (5/5 pts)** +- [x] LF-01: Idiomatic Usage (5/5) - Uses library best practices +""" + + def test_parse_complete_checklist(self, complete_checklist): + result = parse_criteria_checklist(complete_checklist) + + assert result is not None + assert "visual_quality" in result + assert "spec_compliance" in result + assert "data_quality" in result + assert "code_quality" in result + assert "library_features" in result + + def test_parse_category_scores(self, complete_checklist): + result = parse_criteria_checklist(complete_checklist) + + assert result["visual_quality"]["score"] == 36 + assert result["visual_quality"]["max"] == 40 + assert result["spec_compliance"]["score"] == 23 + assert result["spec_compliance"]["max"] == 25 + assert result["code_quality"]["score"] == 10 + assert result["code_quality"]["max"] == 10 + + def test_parse_checklist_items(self, complete_checklist): + result = parse_criteria_checklist(complete_checklist) + + vq_items = result["visual_quality"]["items"] + assert len(vq_items) == 4 + + # Check first item (passed) + vq01 = next(i for i in vq_items if i["id"] == "VQ-01") + assert vq01["name"] == "Text Legibility" + assert vq01["score"] == 10 + assert vq01["max"] == 10 + assert vq01["passed"] is True + assert "readable" in vq01["comment"].lower() + + # Check failed item + vq03 = next(i for i in vq_items if i["id"] == "VQ-03") + assert vq03["name"] == "Color Contrast" + assert vq03["score"] == 8 + assert vq03["max"] == 10 + assert vq03["passed"] is False + + def test_parse_empty_comment_returns_none(self): + result = parse_criteria_checklist("No checklist here, just text.") + + assert result is None or result == {} + + def test_parse_partial_checklist(self): + """Test checklist with only some categories.""" + comment = """ +**Visual Quality (35/40 pts)** +- [x] VQ-01: Text Legibility (10/10) - Good +- [x] VQ-02: No Overlap (8/8) - Good + +**Code Quality (8/10 pts)** +- [x] CQ-01: Clean Code (5/5) - Good +- [ ] CQ-02: Documentation (3/5) - Missing docstring +""" + result = parse_criteria_checklist(comment) + + assert result is not None + assert "visual_quality" in result + assert "code_quality" in result + assert "spec_compliance" not in result + assert "data_quality" not in result + + def test_parse_item_without_comment(self): + comment = """ +**Visual Quality (10/10 pts)** +- [x] VQ-01: Text Legibility (10/10) +""" + result = parse_criteria_checklist(comment) + + if result and "visual_quality" in result: + items = result["visual_quality"]["items"] + if items: + assert items[0]["comment"] == "" + + def test_parse_uppercase_x_checkbox(self): + comment = """ +**Code Quality (10/10 pts)** +- [X] CQ-01: Clean Code (10/10) - Perfect +""" + result = parse_criteria_checklist(comment) + + if result and "code_quality" in result: + items = result["code_quality"]["items"] + if items: + assert items[0]["passed"] is True + + def test_parse_with_pts_singular(self): + """Test parsing with 'pt' instead of 'pts'.""" + comment = """ +**Visual Quality (36/40 pt)** +- [x] VQ-01: Text Legibility (10/10) - Good +""" + result = parse_criteria_checklist(comment) + + # Should handle both 'pt' and 'pts' + if result and "visual_quality" in result: + assert result["visual_quality"]["score"] == 36 + assert result["visual_quality"]["max"] == 40 + + +class TestUpdateMetadataFile: + """Tests for update_metadata_file function - using mocked file operations.""" + + def test_update_adds_review_section(self, tmp_path): + """Test that review data is added to existing metadata.""" + import yaml + + from automation.scripts.backfill_review_metadata import update_metadata_file + + metadata_content = """library: matplotlib +specification_id: scatter-basic +quality_score: 92 +preview_url: https://example.com/plot.png +""" + metadata_file = tmp_path / "matplotlib.yaml" + metadata_file.write_text(metadata_content) + + review_data = { + "image_description": "A scatter plot showing correlation.", + "criteria_checklist": {"visual_quality": {"score": 36, "max": 40, "items": []}}, + "verdict": "APPROVED", + "strengths": ["Clean code"], + "weaknesses": ["Minor issues"], + } + + result = update_metadata_file(metadata_file, review_data, dry_run=False) + + assert result is True + + # Verify file was updated + updated_data = yaml.safe_load(metadata_file.read_text()) + assert "review" in updated_data + assert updated_data["review"]["image_description"] == "A scatter plot showing correlation." + assert updated_data["review"]["verdict"] == "APPROVED" + + def test_update_dry_run_does_not_modify(self, tmp_path): + """Test that dry run doesn't modify files.""" + from automation.scripts.backfill_review_metadata import update_metadata_file + + original_content = """library: matplotlib +specification_id: scatter-basic +quality_score: 92 +""" + metadata_file = tmp_path / "matplotlib.yaml" + metadata_file.write_text(original_content) + + review_data = {"image_description": "New description", "verdict": "APPROVED", "strengths": [], "weaknesses": []} + + result = update_metadata_file(metadata_file, review_data, dry_run=True) + + assert result is True + # File should not be modified + assert metadata_file.read_text() == original_content + + def test_update_missing_file_returns_false(self, tmp_path): + """Test handling of missing file.""" + from automation.scripts.backfill_review_metadata import update_metadata_file + + missing_file = tmp_path / "nonexistent.yaml" + review_data = {"image_description": "Test", "verdict": "APPROVED", "strengths": [], "weaknesses": []} + + result = update_metadata_file(missing_file, review_data, dry_run=False) + + assert result is False + + def test_update_preserves_existing_strengths(self, tmp_path): + """Test that existing strengths are not overwritten if new ones are empty.""" + import yaml + + from automation.scripts.backfill_review_metadata import update_metadata_file + + metadata_content = """library: matplotlib +specification_id: scatter-basic +quality_score: 92 +review: + strengths: + - Existing strength 1 + - Existing strength 2 + weaknesses: [] +""" + metadata_file = tmp_path / "matplotlib.yaml" + metadata_file.write_text(metadata_content) + + review_data = { + "image_description": "New description", + "verdict": "APPROVED", + "strengths": [], # Empty - should not overwrite + "weaknesses": [], + } + + update_metadata_file(metadata_file, review_data, dry_run=False) + + updated_data = yaml.safe_load(metadata_file.read_text()) + # Original strengths should be preserved + assert len(updated_data["review"]["strengths"]) == 2 + assert "Existing strength 1" in updated_data["review"]["strengths"] diff --git a/tests/unit/automation/scripts/test_migrate_metadata_format.py b/tests/unit/automation/scripts/test_migrate_metadata_format.py new file mode 100644 index 0000000000..6248127882 --- /dev/null +++ b/tests/unit/automation/scripts/test_migrate_metadata_format.py @@ -0,0 +1,428 @@ +"""Tests for automation.scripts.migrate_metadata_format module.""" + +from unittest.mock import patch + +import yaml + +from automation.scripts.migrate_metadata_format import ( + extract_title_from_header, + migrate_library_metadata, + migrate_specification_yaml, +) + + +class TestMigrateSpecificationYaml: + """Tests for migrate_specification_yaml function.""" + + def test_remove_history_add_updated(self, tmp_path): + """Test that history is removed and updated is added.""" + yaml_content = """spec_id: scatter-basic +title: Basic Scatter Plot +created: 2025-01-10T08:00:00Z +history: + - date: 2025-01-10 + action: created + - date: 2025-01-12 + action: updated +tags: + plot_type: + - scatter + domain: + - statistics +""" + yaml_file = tmp_path / "specification.yaml" + yaml_file.write_text(yaml_content) + + result = migrate_specification_yaml(yaml_file) + + assert result is True # Modified + + # Verify file content + new_content = yaml_file.read_text() + assert "history:" not in new_content + assert "updated:" in new_content + + def test_skip_already_migrated(self, tmp_path): + """Test that already migrated files are skipped.""" + yaml_content = """spec_id: scatter-basic +title: Basic Scatter Plot +created: 2025-01-10T08:00:00Z +updated: 2025-01-15T10:00:00Z +tags: + plot_type: + - scatter +""" + yaml_file = tmp_path / "specification.yaml" + yaml_file.write_text(yaml_content) + + result = migrate_specification_yaml(yaml_file) + + assert result is False # Not modified + + def test_missing_file_returns_false(self, tmp_path): + yaml_file = tmp_path / "nonexistent.yaml" + + result = migrate_specification_yaml(yaml_file) + + assert result is False + + def test_empty_file_returns_false(self, tmp_path): + yaml_file = tmp_path / "empty.yaml" + yaml_file.write_text("") + + result = migrate_specification_yaml(yaml_file) + + assert result is False + + def test_updated_uses_created_if_available(self, tmp_path): + """Test that updated is set to created value if not present.""" + yaml_content = """spec_id: test-plot +title: Test Plot +created: 2025-01-10T08:00:00Z +history: + - date: 2025-01-10 +""" + yaml_file = tmp_path / "specification.yaml" + yaml_file.write_text(yaml_content) + + migrate_specification_yaml(yaml_file) + + data = yaml.safe_load(yaml_file.read_text()) + # Updated should match created + assert "updated" in data + + +class TestMigrateLibraryMetadata: + """Tests for migrate_library_metadata function.""" + + def test_flatten_current_structure(self, tmp_path): + """Test flattening of nested current: structure.""" + yaml_content = """library: matplotlib +specification_id: scatter-basic +current: + generated_at: 2025-01-10T08:00:00Z + generated_by: claude-opus-4-5 + quality_score: 92 + python_version: "3.13" + library_version: "3.10.0" + version: 1 +history: + - version: 1 + date: 2025-01-10 +preview_url: https://example.com/plot.png +""" + yaml_file = tmp_path / "matplotlib.yaml" + yaml_file.write_text(yaml_content) + + # Patch PLOTS_DIR to use tmp_path so relative_to works + with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): + result = migrate_library_metadata(yaml_file) + + assert result is True + + # Verify flattened structure + new_data = yaml.safe_load(yaml_file.read_text()) + assert "current" not in new_data + assert "history" not in new_data + assert "version" not in new_data + assert new_data["quality_score"] == 92 + assert new_data["generated_by"] == "claude-opus-4-5" + assert new_data["python_version"] == "3.13" + + def test_adds_created_from_generated_at(self, tmp_path): + """Test that created is added from generated_at.""" + yaml_content = """library: seaborn +specification_id: scatter-basic +current: + generated_at: 2025-01-10T08:00:00Z + quality_score: 88 +""" + yaml_file = tmp_path / "seaborn.yaml" + yaml_file.write_text(yaml_content) + + with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): + migrate_library_metadata(yaml_file) + + new_data = yaml.safe_load(yaml_file.read_text()) + assert "created" in new_data + assert "updated" in new_data + + def test_adds_empty_review_section(self, tmp_path): + """Test that empty review section is added.""" + yaml_content = """library: plotly +specification_id: scatter-basic +quality_score: 90 +""" + yaml_file = tmp_path / "plotly.yaml" + yaml_file.write_text(yaml_content) + + with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): + migrate_library_metadata(yaml_file) + + new_data = yaml.safe_load(yaml_file.read_text()) + assert "review" in new_data + assert "strengths" in new_data["review"] + assert "weaknesses" in new_data["review"] + + def test_skip_already_migrated(self, tmp_path): + """Test that already migrated files are skipped.""" + yaml_content = """library: matplotlib +specification_id: scatter-basic +created: 2025-01-10T08:00:00Z +updated: 2025-01-15T10:00:00Z +quality_score: 92 +review: + strengths: [] + weaknesses: [] + improvements: [] +""" + yaml_file = tmp_path / "matplotlib.yaml" + yaml_file.write_text(yaml_content) + + result = migrate_library_metadata(yaml_file) + + # File has all required fields, should not be modified + # (unless current: or history: present) + assert result is False + + def test_missing_file_returns_false(self, tmp_path): + yaml_file = tmp_path / "nonexistent.yaml" + + result = migrate_library_metadata(yaml_file) + + assert result is False + + def test_empty_file_returns_false(self, tmp_path): + yaml_file = tmp_path / "empty.yaml" + yaml_file.write_text("") + + result = migrate_library_metadata(yaml_file) + + assert result is False + + def test_removes_version_field(self, tmp_path): + """Test that version field is removed.""" + yaml_content = """library: bokeh +specification_id: scatter-basic +version: 3 +quality_score: 85 +""" + yaml_file = tmp_path / "bokeh.yaml" + yaml_file.write_text(yaml_content) + + with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): + migrate_library_metadata(yaml_file) + + new_data = yaml.safe_load(yaml_file.read_text()) + assert "version" not in new_data + + +class TestExtractTitleFromHeader: + """Tests for extract_title_from_header function.""" + + def test_extract_standard_format(self): + header = '''""" pyplots.ai +scatter-basic: Basic Scatter Plot +Library: matplotlib 3.10.0 | Python 3.13 +Quality: 92/100 | Created: 2025-01-10 +"""''' + + result = extract_title_from_header(header) + + assert result == "Basic Scatter Plot" + + def test_extract_with_colon_in_title(self): + """Test title extraction when title contains colons.""" + header = '''""" +heatmap-correlation: Correlation Matrix: Visualizing Relationships +Library: seaborn +"""''' + + result = extract_title_from_header(header) + + # Should get everything after first colon on the spec-id line + assert "Correlation Matrix" in result + + def test_extract_simple_title(self): + header = '''""" +bar-basic: Simple Bar Chart +Library: matplotlib +"""''' + + result = extract_title_from_header(header) + + assert result == "Simple Bar Chart" + + def test_extract_empty_header(self): + result = extract_title_from_header('""""""') + + assert result == "" + + def test_extract_only_library_line(self): + """Test that header with only library line returns empty string.""" + header = '''""" +Library: matplotlib +"""''' + + result = extract_title_from_header(header) + + # Library: line is ignored, no other title line present + assert result == "" + + def test_extract_ignores_library_line(self): + """Test that Library: line is not mistaken for title.""" + header = '''""" +scatter-basic: My Plot +Library: matplotlib 3.10.0 +"""''' + + result = extract_title_from_header(header) + + assert result == "My Plot" + assert "matplotlib" not in result + + def test_extract_with_newlines(self): + header = '''""" + +scatter-3d: 3D Scatter Plot + +Library: plotly 5.18.0 +"""''' + + result = extract_title_from_header(header) + + assert result == "3D Scatter Plot" + + def test_extract_multiword_title(self): + header = '''""" +violin-grouped: Grouped Violin Plot with Multiple Categories +Library: seaborn +"""''' + + result = extract_title_from_header(header) + + assert result == "Grouped Violin Plot with Multiple Categories" + + +class TestMigratePlot: + """Integration tests for migrate_plot function.""" + + def test_migrate_complete_plot_directory(self, tmp_path): + """Test migration of a complete plot directory.""" + from automation.scripts.migrate_metadata_format import migrate_plot + + # Create plot directory structure + plot_dir = tmp_path / "scatter-basic" + plot_dir.mkdir() + + # specification.yaml with history + (plot_dir / "specification.yaml").write_text("""spec_id: scatter-basic +title: Basic Scatter Plot +created: 2025-01-10T08:00:00Z +history: + - date: 2025-01-10 +tags: + plot_type: + - scatter +""") + + # metadata/ directory + meta_dir = plot_dir / "metadata" + meta_dir.mkdir() + + # metadata/matplotlib.yaml with current: structure + (meta_dir / "matplotlib.yaml").write_text("""library: matplotlib +specification_id: scatter-basic +current: + generated_at: 2025-01-10T08:00:00Z + quality_score: 92 + python_version: "3.13" + library_version: "3.10.0" +preview_url: https://example.com/plot.png +""") + + # implementations/ directory + impl_dir = plot_dir / "implementations" + impl_dir.mkdir() + + # implementations/matplotlib.py + (impl_dir / "matplotlib.py").write_text('''""" +scatter-basic: Basic Scatter Plot +Library: matplotlib +""" +import matplotlib.pyplot as plt +plt.plot([1, 2, 3]) +''') + + # Run migration with patched PLOTS_DIR + with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): + stats = migrate_plot(plot_dir) + + assert stats["spec"] == 1 # specification.yaml migrated + assert stats["meta"] == 1 # matplotlib.yaml migrated + + # Verify specification.yaml + spec_data = yaml.safe_load((plot_dir / "specification.yaml").read_text()) + assert "history" not in spec_data + assert "updated" in spec_data + + # Verify matplotlib.yaml + meta_data = yaml.safe_load((meta_dir / "matplotlib.yaml").read_text()) + assert "current" not in meta_data + assert meta_data["quality_score"] == 92 + assert "review" in meta_data + + def test_migrate_skips_underscore_files(self, tmp_path): + """Test that files starting with underscore are skipped.""" + from automation.scripts.migrate_metadata_format import migrate_plot + + plot_dir = tmp_path / "test-plot" + plot_dir.mkdir() + + (plot_dir / "specification.yaml").write_text("""spec_id: test-plot +title: Test +created: 2025-01-10T08:00:00Z +updated: 2025-01-10T08:00:00Z +tags: + plot_type: + - test +""") + + impl_dir = plot_dir / "implementations" + impl_dir.mkdir() + + # Use a header that won't be migrated (already correct format) + # Note: library_version must match metadata for header to be identical + (impl_dir / "matplotlib.py").write_text('''""" pyplots.ai +test-plot: Test +Library: matplotlib 3.10.0 | Python 3.13 +Quality: 92/100 | Created: 2025-01-10 +""" +import matplotlib.pyplot as plt +''') + (impl_dir / "_template.py").write_text('"""template - should be skipped"""') + + meta_dir = plot_dir / "metadata" + meta_dir.mkdir() + + (meta_dir / "matplotlib.yaml").write_text("""library: matplotlib +specification_id: test-plot +created: 2025-01-10T08:00:00Z +updated: 2025-01-10T08:00:00Z +python_version: "3.13" +library_version: "3.10.0" +quality_score: 92 +review: + strengths: [] + weaknesses: [] + improvements: [] +""") + + with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): + stats = migrate_plot(plot_dir) + + # _template.py should be skipped (only matplotlib.py processed) + # matplotlib.py header is already in correct format, so no migration needed + assert stats["impl"] == 0 + # Verify _template.py was not modified + assert (impl_dir / "_template.py").read_text() == '"""template - should be skipped"""' diff --git a/tests/unit/automation/scripts/test_sync_to_postgres.py b/tests/unit/automation/scripts/test_sync_to_postgres.py new file mode 100644 index 0000000000..1d509b86ac --- /dev/null +++ b/tests/unit/automation/scripts/test_sync_to_postgres.py @@ -0,0 +1,554 @@ +"""Tests for automation.scripts.sync_to_postgres module.""" + +from datetime import datetime + +from automation.scripts.sync_to_postgres import ( + convert_datetimes_to_strings, + parse_bullet_points, + parse_library_metadata_yaml, + parse_metadata_yaml, + parse_spec_markdown, + parse_timestamp, + scan_plot_directory, +) + + +class TestParseTimestamp: + """Tests for parse_timestamp function.""" + + def test_parse_datetime_object(self): + dt = datetime(2025, 1, 10, 8, 0, 0) + result = parse_timestamp(dt) + assert result == dt + assert result.tzinfo is None + + def test_parse_datetime_with_timezone(self): + from datetime import timezone + + dt = datetime(2025, 1, 10, 8, 0, 0, tzinfo=timezone.utc) + result = parse_timestamp(dt) + assert result == datetime(2025, 1, 10, 8, 0, 0) + assert result.tzinfo is None + + def test_parse_iso_string_with_z(self): + result = parse_timestamp("2025-01-10T08:00:00Z") + assert result == datetime(2025, 1, 10, 8, 0, 0) + + def test_parse_iso_string_with_offset(self): + result = parse_timestamp("2025-01-10T08:00:00+00:00") + assert result == datetime(2025, 1, 10, 8, 0, 0) + + def test_parse_invalid_string(self): + result = parse_timestamp("not-a-date") + assert result is None + + def test_parse_none(self): + result = parse_timestamp(None) + assert result is None + + def test_parse_integer_returns_none(self): + result = parse_timestamp(12345) + assert result is None + + +class TestParseBulletPoints: + """Tests for parse_bullet_points function.""" + + def test_parse_dash_bullets(self): + text = "- Item 1\n- Item 2\n- Item 3" + result = parse_bullet_points(text) + assert result == ["Item 1", "Item 2", "Item 3"] + + def test_parse_asterisk_bullets(self): + text = "* Item 1\n* Item 2" + result = parse_bullet_points(text) + assert result == ["Item 1", "Item 2"] + + def test_parse_mixed_bullets(self): + text = "- Item 1\n* Item 2\n- Item 3" + result = parse_bullet_points(text) + assert result == ["Item 1", "Item 2", "Item 3"] + + def test_parse_with_leading_whitespace(self): + text = " - Item 1\n - Item 2" + result = parse_bullet_points(text) + assert result == ["Item 1", "Item 2"] + + def test_parse_empty_string(self): + result = parse_bullet_points("") + assert result == [] + + def test_parse_no_bullets(self): + text = "Just some text\nwithout bullets" + result = parse_bullet_points(text) + assert result == [] + + def test_parse_bullets_with_extra_spaces(self): + text = "- Item with spaces \n- Another item" + result = parse_bullet_points(text) + assert result == ["Item with spaces", "Another item"] + + +class TestConvertDatetimesToStrings: + """Tests for convert_datetimes_to_strings function.""" + + def test_convert_datetime(self): + dt = datetime(2025, 1, 10, 8, 0, 0) + result = convert_datetimes_to_strings(dt) + assert result == "2025-01-10T08:00:00" + + def test_convert_string_unchanged(self): + result = convert_datetimes_to_strings("test string") + assert result == "test string" + + def test_convert_integer_unchanged(self): + result = convert_datetimes_to_strings(42) + assert result == 42 + + def test_convert_nested_dict(self): + dt = datetime(2025, 1, 10, 8, 0, 0) + data = {"created": dt, "nested": {"updated": dt}, "name": "test"} + result = convert_datetimes_to_strings(data) + assert result["created"] == "2025-01-10T08:00:00" + assert result["nested"]["updated"] == "2025-01-10T08:00:00" + assert result["name"] == "test" + + def test_convert_list(self): + dt = datetime(2025, 1, 10, 8, 0, 0) + result = convert_datetimes_to_strings([dt, "string", 123]) + assert result == ["2025-01-10T08:00:00", "string", 123] + + def test_convert_empty_dict(self): + result = convert_datetimes_to_strings({}) + assert result == {} + + def test_convert_empty_list(self): + result = convert_datetimes_to_strings([]) + assert result == [] + + +class TestParseSpecMarkdown: + """Tests for parse_spec_markdown function.""" + + def test_parse_complete_spec(self, tmp_path): + spec_content = """# scatter-basic: Basic Scatter Plot + +## Description +A simple scatter plot showing the relationship between two variables. + +## Applications +- Correlation analysis +- Data exploration +- Trend identification + +## Data +- X values (numeric) +- Y values (numeric) + +## Notes +- Use alpha for overlapping points +- Consider adding a trend line +""" + spec_dir = tmp_path / "scatter-basic" + spec_dir.mkdir() + spec_file = spec_dir / "specification.md" + spec_file.write_text(spec_content) + + result = parse_spec_markdown(spec_file) + + assert result["id"] == "scatter-basic" + assert result["title"] == "Basic Scatter Plot" + assert "relationship between two variables" in result["description"] + assert result["applications"] == ["Correlation analysis", "Data exploration", "Trend identification"] + assert len(result["data"]) == 2 + assert result["notes"] == ["Use alpha for overlapping points", "Consider adding a trend line"] + + def test_parse_spec_without_notes(self, tmp_path): + spec_content = """# bar-basic: Basic Bar Chart + +## Description +A simple bar chart for comparing categories. + +## Applications +- Category comparison + +## Data +- Categories +- Values +""" + spec_dir = tmp_path / "bar-basic" + spec_dir.mkdir() + spec_file = spec_dir / "specification.md" + spec_file.write_text(spec_content) + + result = parse_spec_markdown(spec_file) + + assert result["id"] == "bar-basic" + assert result["title"] == "Basic Bar Chart" + assert result["notes"] == [] + + def test_parse_spec_without_title_match(self, tmp_path): + spec_content = """# Just a Title Without Colon + +## Description +Some description. + +## Applications +- One application + +## Data +- Some data +""" + spec_dir = tmp_path / "no-colon-title" + spec_dir.mkdir() + spec_file = spec_dir / "specification.md" + spec_file.write_text(spec_content) + + result = parse_spec_markdown(spec_file) + + # Falls back to directory name + assert result["id"] == "no-colon-title" + assert result["title"] == "no-colon-title" + + def test_parse_spec_minimal_sections(self, tmp_path): + """Test parsing spec with minimal content in sections.""" + spec_content = """# minimal-spec: Minimal Spec + +## Description +Minimal description. + +## Applications +- One app + +## Data +- One data point +""" + spec_dir = tmp_path / "minimal-spec" + spec_dir.mkdir() + spec_file = spec_dir / "specification.md" + spec_file.write_text(spec_content) + + result = parse_spec_markdown(spec_file) + + assert result["id"] == "minimal-spec" + assert result["description"] == "Minimal description." + assert result["applications"] == ["One app"] + assert result["data"] == ["One data point"] + + +class TestParseMetadataYaml: + """Tests for parse_metadata_yaml function.""" + + def test_parse_valid_spec_yaml(self, tmp_path): + yaml_content = """spec_id: scatter-basic +title: Basic Scatter Plot +created: 2025-01-10T08:00:00Z +tags: + plot_type: [scatter] + domain: [statistics] +""" + yaml_file = tmp_path / "specification.yaml" + yaml_file.write_text(yaml_content) + + result = parse_metadata_yaml(yaml_file) + + assert result is not None + assert result["spec_id"] == "scatter-basic" + assert result["title"] == "Basic Scatter Plot" + assert result["tags"]["plot_type"] == ["scatter"] + + def test_parse_with_specification_id(self, tmp_path): + """Test backwards compatibility with specification_id key.""" + yaml_content = """specification_id: scatter-basic +title: Basic Scatter Plot +""" + yaml_file = tmp_path / "spec.yaml" + yaml_file.write_text(yaml_content) + + result = parse_metadata_yaml(yaml_file) + + assert result is not None + assert result["specification_id"] == "scatter-basic" + + def test_parse_missing_spec_id_returns_none(self, tmp_path): + yaml_content = """title: No Spec ID +tags: + plot_type: [scatter] +""" + yaml_file = tmp_path / "invalid.yaml" + yaml_file.write_text(yaml_content) + + result = parse_metadata_yaml(yaml_file) + + assert result is None + + def test_parse_empty_file_returns_none(self, tmp_path): + yaml_file = tmp_path / "empty.yaml" + yaml_file.write_text("") + + result = parse_metadata_yaml(yaml_file) + + assert result is None + + def test_parse_invalid_yaml_returns_none(self, tmp_path): + yaml_file = tmp_path / "invalid.yaml" + yaml_file.write_text("not: valid: yaml: : here") + + result = parse_metadata_yaml(yaml_file) + + assert result is None + + +class TestParseLibraryMetadataYaml: + """Tests for parse_library_metadata_yaml function.""" + + def test_parse_valid_library_metadata(self, tmp_path): + yaml_content = """library: matplotlib +specification_id: scatter-basic +quality_score: 92 +preview_url: https://storage.example.com/plot.png +python_version: "3.13" +library_version: "3.10.0" +""" + yaml_file = tmp_path / "matplotlib.yaml" + yaml_file.write_text(yaml_content) + + result = parse_library_metadata_yaml(yaml_file) + + assert result is not None + assert result["library"] == "matplotlib" + assert result["quality_score"] == 92 + assert result["python_version"] == "3.13" + + def test_parse_missing_library_returns_none(self, tmp_path): + yaml_content = """specification_id: scatter-basic +quality_score: 92 +""" + yaml_file = tmp_path / "no_library.yaml" + yaml_file.write_text(yaml_content) + + result = parse_library_metadata_yaml(yaml_file) + + assert result is None + + def test_parse_with_review_section(self, tmp_path): + yaml_content = """library: seaborn +specification_id: scatter-basic +quality_score: 88 +review: + strengths: + - Clean code + - Good colors + weaknesses: + - Missing grid +""" + yaml_file = tmp_path / "seaborn.yaml" + yaml_file.write_text(yaml_content) + + result = parse_library_metadata_yaml(yaml_file) + + assert result is not None + assert result["review"]["strengths"] == ["Clean code", "Good colors"] + assert result["review"]["weaknesses"] == ["Missing grid"] + + +class TestScanPlotDirectory: + """Tests for scan_plot_directory function.""" + + def test_scan_complete_directory(self, tmp_path): + # Create complete plot directory structure + plot_dir = tmp_path / "scatter-basic" + plot_dir.mkdir() + + # specification.md + (plot_dir / "specification.md").write_text("""# scatter-basic: Basic Scatter Plot + +## Description +A simple scatter plot. + +## Applications +- Correlation analysis + +## Data +- X values +- Y values +""") + + # specification.yaml + (plot_dir / "specification.yaml").write_text("""spec_id: scatter-basic +title: Basic Scatter Plot +created: 2025-01-10T08:00:00Z +issue: 42 +suggested: testuser +tags: + plot_type: [scatter] + domain: [statistics] +""") + + # implementations/ + impl_dir = plot_dir / "implementations" + impl_dir.mkdir() + (impl_dir / "matplotlib.py").write_text('''"""pyplots.ai""" +import matplotlib.pyplot as plt +plt.plot([1, 2, 3]) +plt.savefig("plot.png") +''') + + # metadata/ + meta_dir = plot_dir / "metadata" + meta_dir.mkdir() + (meta_dir / "matplotlib.yaml").write_text("""library: matplotlib +specification_id: scatter-basic +quality_score: 92 +preview_url: https://storage.example.com/plot.png +python_version: "3.13" +library_version: "3.10.0" +review: + strengths: + - Clean code + weaknesses: [] +""") + + result = scan_plot_directory(plot_dir) + + assert result is not None + assert result["spec"]["id"] == "scatter-basic" + assert result["spec"]["title"] == "Basic Scatter Plot" + assert result["spec"]["issue"] == 42 + assert result["spec"]["suggested"] == "testuser" + assert len(result["implementations"]) == 1 + + impl = result["implementations"][0] + assert impl["spec_id"] == "scatter-basic" + assert impl["library_id"] == "matplotlib" + assert impl["quality_score"] == 92 + assert impl["python_version"] == "3.13" + assert impl["review_strengths"] == ["Clean code"] + + def test_scan_missing_spec_returns_none(self, tmp_path): + plot_dir = tmp_path / "empty-plot" + plot_dir.mkdir() + + result = scan_plot_directory(plot_dir) + + assert result is None + + def test_scan_legacy_spec_md(self, tmp_path): + """Test scanning with legacy spec.md filename.""" + plot_dir = tmp_path / "legacy-plot" + plot_dir.mkdir() + + # Use legacy filename + (plot_dir / "spec.md").write_text("""# legacy-plot: Legacy Plot + +## Description +A legacy format plot. + +## Applications +- Testing + +## Data +- Values +""") + + result = scan_plot_directory(plot_dir) + + assert result is not None + assert result["spec"]["id"] == "legacy-plot" + assert result["spec"]["title"] == "Legacy Plot" + + def test_scan_skips_underscore_files(self, tmp_path): + """Test that files starting with underscore are skipped.""" + plot_dir = tmp_path / "test-plot" + plot_dir.mkdir() + + (plot_dir / "specification.md").write_text("""# test-plot: Test + +## Description +Test plot. + +## Applications +- Testing + +## Data +- Values +""") + + impl_dir = plot_dir / "implementations" + impl_dir.mkdir() + (impl_dir / "matplotlib.py").write_text("# matplotlib") + (impl_dir / "_template.py").write_text("# template - should be skipped") + + result = scan_plot_directory(plot_dir) + + assert result is not None + assert len(result["implementations"]) == 1 + assert result["implementations"][0]["library_id"] == "matplotlib" + + def test_scan_without_implementations(self, tmp_path): + """Test scanning a plot with only specification, no implementations.""" + plot_dir = tmp_path / "spec-only" + plot_dir.mkdir() + + (plot_dir / "specification.md").write_text("""# spec-only: Spec Only + +## Description +A specification without implementations. + +## Applications +- Future work + +## Data +- TBD +""") + + (plot_dir / "specification.yaml").write_text("""spec_id: spec-only +title: Spec Only +created: 2025-01-10T08:00:00Z +tags: + plot_type: [scatter] +""") + + result = scan_plot_directory(plot_dir) + + assert result is not None + assert result["spec"]["id"] == "spec-only" + assert result["implementations"] == [] + + def test_scan_with_legacy_nested_current(self, tmp_path): + """Test scanning with legacy current: nested structure in metadata.""" + plot_dir = tmp_path / "legacy-nested" + plot_dir.mkdir() + + (plot_dir / "specification.md").write_text("""# legacy-nested: Legacy Nested + +## Description +Test. + +## Applications +- Test + +## Data +- Test +""") + + impl_dir = plot_dir / "implementations" + impl_dir.mkdir() + (impl_dir / "matplotlib.py").write_text("# code") + + meta_dir = plot_dir / "metadata" + meta_dir.mkdir() + (meta_dir / "matplotlib.yaml").write_text("""library: matplotlib +specification_id: legacy-nested +current: + generated_at: 2025-01-10T08:00:00Z + quality_score: 85 + python_version: "3.12" +""") + + result = scan_plot_directory(plot_dir) + + assert result is not None + impl = result["implementations"][0] + assert impl["quality_score"] == 85 + assert impl["python_version"] == "3.12" From 6f961b2e4f487b203cdea6b049978af5b7957674 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:43:42 +0100 Subject: [PATCH 2/3] test(automation): remove tests for one-time migration scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove tests for backfill_review_metadata.py and migrate_metadata_format.py as these are one-time migration scripts that have already been executed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../scripts/test_backfill_review_metadata.py | 405 ----------------- .../scripts/test_migrate_metadata_format.py | 428 ------------------ 2 files changed, 833 deletions(-) delete mode 100644 tests/unit/automation/scripts/test_backfill_review_metadata.py delete mode 100644 tests/unit/automation/scripts/test_migrate_metadata_format.py diff --git a/tests/unit/automation/scripts/test_backfill_review_metadata.py b/tests/unit/automation/scripts/test_backfill_review_metadata.py deleted file mode 100644 index 07c2c9c32f..0000000000 --- a/tests/unit/automation/scripts/test_backfill_review_metadata.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Tests for automation.scripts.backfill_review_metadata module.""" - - -import pytest - -from automation.scripts.backfill_review_metadata import parse_ai_review_comment, parse_criteria_checklist - - -class TestParseAiReviewComment: - """Tests for parse_ai_review_comment function.""" - - @pytest.fixture - def complete_review_comment(self): - return """## AI Review - -### Image Description -> The plot shows a scatter plot with 100 data points displaying -> a positive correlation. Points are rendered in blue with 70% -> opacity. Axes are clearly labeled and include units. - -### Strengths -- Clean code structure with proper imports -- Good use of alpha for overlapping points -- Proper axis labels with units -- Appropriate figure size - -### Weaknesses -- Grid could be more subtle -- Consider adding a trend line -- Title could be more descriptive - -### Verdict: APPROVED - -**Quality Score: 92/100** -""" - - def test_parse_complete_review(self, complete_review_comment): - result = parse_ai_review_comment(complete_review_comment) - - assert result is not None - assert "scatter plot with 100 data points" in result["image_description"] - assert "positive correlation" in result["image_description"] - assert result["verdict"] == "APPROVED" - assert len(result["strengths"]) == 4 - assert "Clean code structure" in result["strengths"][0] - assert len(result["weaknesses"]) == 3 - assert "Grid could be more subtle" in result["weaknesses"][0] - - def test_parse_rejected_review(self): - comment = """## AI Review - -### Image Description -> The plot has significant rendering issues. -> Text is overlapping and unreadable. - -### Strengths -- Uses correct library imports - -### Weaknesses -- Major rendering problem with overlapping elements -- Missing axis labels -- Figure size too small -- No title - -### Verdict: REJECTED - -**Quality Score: 45/100** -""" - result = parse_ai_review_comment(comment) - - assert result is not None - assert result["verdict"] == "REJECTED" - assert len(result["strengths"]) == 1 - assert len(result["weaknesses"]) == 4 - - def test_parse_verdict_case_insensitive(self): - comment = """## AI Review - -### Image Description -> Test plot. - -### Verdict: approved -""" - result = parse_ai_review_comment(comment) - - assert result["verdict"] == "APPROVED" - - def test_non_review_comment_returns_none(self): - comment = "This is just a regular comment without AI Review header." - - result = parse_ai_review_comment(comment) - - assert result is None - - def test_partial_review_comment_returns_none(self): - comment = "## Not AI Review\n\nSome other content." - - result = parse_ai_review_comment(comment) - - assert result is None - - def test_parse_multiline_image_description(self): - comment = """## AI Review - -### Image Description -> Line 1 of the description providing context. -> Line 2 continues with more details about the plot. -> Line 3 describes specific visual elements. -> Line 4 concludes the description. - -### Verdict: APPROVED -""" - result = parse_ai_review_comment(comment) - - assert result is not None - desc = result["image_description"] - assert "Line 1" in desc - assert "Line 2" in desc - assert "Line 3" in desc - assert "Line 4" in desc - # Should not have leading > characters - assert not desc.startswith(">") - - def test_parse_empty_strengths_weaknesses(self): - comment = """## AI Review - -### Image Description -> Simple plot description. - -### Strengths - -### Weaknesses - -### Verdict: APPROVED -""" - result = parse_ai_review_comment(comment) - - assert result is not None - assert result["strengths"] == [] - assert result["weaknesses"] == [] - - def test_parse_asterisk_bullets(self): - comment = """## AI Review - -### Image Description -> Test. - -### Strengths -* First strength -* Second strength - -### Weaknesses -* First weakness - -### Verdict: APPROVED -""" - result = parse_ai_review_comment(comment) - - assert result is not None - assert len(result["strengths"]) == 2 - assert "First strength" in result["strengths"] - assert "Second strength" in result["strengths"] - - def test_parse_missing_sections(self): - """Test that missing optional sections are handled gracefully.""" - comment = """## AI Review - -### Verdict: APPROVED -""" - result = parse_ai_review_comment(comment) - - assert result is not None - assert result["verdict"] == "APPROVED" - assert result["image_description"] is None - assert result["strengths"] == [] - assert result["weaknesses"] == [] - - -class TestParseCriteriaChecklist: - """Tests for parse_criteria_checklist function.""" - - @pytest.fixture - def complete_checklist(self): - return """ -**Visual Quality (36/40 pts)** -- [x] VQ-01: Text Legibility (10/10) - All text is readable at full size -- [x] VQ-02: No Overlap (8/8) - Elements are properly spaced -- [ ] VQ-03: Color Contrast (8/10) - Could use higher contrast -- [x] VQ-04: Resolution (10/10) - High quality output at 300 DPI - -**Spec Compliance (23/25 pts)** -- [x] SC-01: Data Requirements (15/15) - All required data columns shown -- [ ] SC-02: Visual Style (8/10) - Minor style deviations - -**Data Quality (18/20 pts)** -- [x] DQ-01: Accuracy (10/10) - Data represented correctly -- [ ] DQ-02: Completeness (8/10) - Some edge cases not shown - -**Code Quality (10/10 pts)** -- [x] CQ-01: Clean Code (5/5) - Well structured and readable -- [x] CQ-02: Documentation (5/5) - Good docstring and comments - -**Library Features (5/5 pts)** -- [x] LF-01: Idiomatic Usage (5/5) - Uses library best practices -""" - - def test_parse_complete_checklist(self, complete_checklist): - result = parse_criteria_checklist(complete_checklist) - - assert result is not None - assert "visual_quality" in result - assert "spec_compliance" in result - assert "data_quality" in result - assert "code_quality" in result - assert "library_features" in result - - def test_parse_category_scores(self, complete_checklist): - result = parse_criteria_checklist(complete_checklist) - - assert result["visual_quality"]["score"] == 36 - assert result["visual_quality"]["max"] == 40 - assert result["spec_compliance"]["score"] == 23 - assert result["spec_compliance"]["max"] == 25 - assert result["code_quality"]["score"] == 10 - assert result["code_quality"]["max"] == 10 - - def test_parse_checklist_items(self, complete_checklist): - result = parse_criteria_checklist(complete_checklist) - - vq_items = result["visual_quality"]["items"] - assert len(vq_items) == 4 - - # Check first item (passed) - vq01 = next(i for i in vq_items if i["id"] == "VQ-01") - assert vq01["name"] == "Text Legibility" - assert vq01["score"] == 10 - assert vq01["max"] == 10 - assert vq01["passed"] is True - assert "readable" in vq01["comment"].lower() - - # Check failed item - vq03 = next(i for i in vq_items if i["id"] == "VQ-03") - assert vq03["name"] == "Color Contrast" - assert vq03["score"] == 8 - assert vq03["max"] == 10 - assert vq03["passed"] is False - - def test_parse_empty_comment_returns_none(self): - result = parse_criteria_checklist("No checklist here, just text.") - - assert result is None or result == {} - - def test_parse_partial_checklist(self): - """Test checklist with only some categories.""" - comment = """ -**Visual Quality (35/40 pts)** -- [x] VQ-01: Text Legibility (10/10) - Good -- [x] VQ-02: No Overlap (8/8) - Good - -**Code Quality (8/10 pts)** -- [x] CQ-01: Clean Code (5/5) - Good -- [ ] CQ-02: Documentation (3/5) - Missing docstring -""" - result = parse_criteria_checklist(comment) - - assert result is not None - assert "visual_quality" in result - assert "code_quality" in result - assert "spec_compliance" not in result - assert "data_quality" not in result - - def test_parse_item_without_comment(self): - comment = """ -**Visual Quality (10/10 pts)** -- [x] VQ-01: Text Legibility (10/10) -""" - result = parse_criteria_checklist(comment) - - if result and "visual_quality" in result: - items = result["visual_quality"]["items"] - if items: - assert items[0]["comment"] == "" - - def test_parse_uppercase_x_checkbox(self): - comment = """ -**Code Quality (10/10 pts)** -- [X] CQ-01: Clean Code (10/10) - Perfect -""" - result = parse_criteria_checklist(comment) - - if result and "code_quality" in result: - items = result["code_quality"]["items"] - if items: - assert items[0]["passed"] is True - - def test_parse_with_pts_singular(self): - """Test parsing with 'pt' instead of 'pts'.""" - comment = """ -**Visual Quality (36/40 pt)** -- [x] VQ-01: Text Legibility (10/10) - Good -""" - result = parse_criteria_checklist(comment) - - # Should handle both 'pt' and 'pts' - if result and "visual_quality" in result: - assert result["visual_quality"]["score"] == 36 - assert result["visual_quality"]["max"] == 40 - - -class TestUpdateMetadataFile: - """Tests for update_metadata_file function - using mocked file operations.""" - - def test_update_adds_review_section(self, tmp_path): - """Test that review data is added to existing metadata.""" - import yaml - - from automation.scripts.backfill_review_metadata import update_metadata_file - - metadata_content = """library: matplotlib -specification_id: scatter-basic -quality_score: 92 -preview_url: https://example.com/plot.png -""" - metadata_file = tmp_path / "matplotlib.yaml" - metadata_file.write_text(metadata_content) - - review_data = { - "image_description": "A scatter plot showing correlation.", - "criteria_checklist": {"visual_quality": {"score": 36, "max": 40, "items": []}}, - "verdict": "APPROVED", - "strengths": ["Clean code"], - "weaknesses": ["Minor issues"], - } - - result = update_metadata_file(metadata_file, review_data, dry_run=False) - - assert result is True - - # Verify file was updated - updated_data = yaml.safe_load(metadata_file.read_text()) - assert "review" in updated_data - assert updated_data["review"]["image_description"] == "A scatter plot showing correlation." - assert updated_data["review"]["verdict"] == "APPROVED" - - def test_update_dry_run_does_not_modify(self, tmp_path): - """Test that dry run doesn't modify files.""" - from automation.scripts.backfill_review_metadata import update_metadata_file - - original_content = """library: matplotlib -specification_id: scatter-basic -quality_score: 92 -""" - metadata_file = tmp_path / "matplotlib.yaml" - metadata_file.write_text(original_content) - - review_data = {"image_description": "New description", "verdict": "APPROVED", "strengths": [], "weaknesses": []} - - result = update_metadata_file(metadata_file, review_data, dry_run=True) - - assert result is True - # File should not be modified - assert metadata_file.read_text() == original_content - - def test_update_missing_file_returns_false(self, tmp_path): - """Test handling of missing file.""" - from automation.scripts.backfill_review_metadata import update_metadata_file - - missing_file = tmp_path / "nonexistent.yaml" - review_data = {"image_description": "Test", "verdict": "APPROVED", "strengths": [], "weaknesses": []} - - result = update_metadata_file(missing_file, review_data, dry_run=False) - - assert result is False - - def test_update_preserves_existing_strengths(self, tmp_path): - """Test that existing strengths are not overwritten if new ones are empty.""" - import yaml - - from automation.scripts.backfill_review_metadata import update_metadata_file - - metadata_content = """library: matplotlib -specification_id: scatter-basic -quality_score: 92 -review: - strengths: - - Existing strength 1 - - Existing strength 2 - weaknesses: [] -""" - metadata_file = tmp_path / "matplotlib.yaml" - metadata_file.write_text(metadata_content) - - review_data = { - "image_description": "New description", - "verdict": "APPROVED", - "strengths": [], # Empty - should not overwrite - "weaknesses": [], - } - - update_metadata_file(metadata_file, review_data, dry_run=False) - - updated_data = yaml.safe_load(metadata_file.read_text()) - # Original strengths should be preserved - assert len(updated_data["review"]["strengths"]) == 2 - assert "Existing strength 1" in updated_data["review"]["strengths"] diff --git a/tests/unit/automation/scripts/test_migrate_metadata_format.py b/tests/unit/automation/scripts/test_migrate_metadata_format.py deleted file mode 100644 index 6248127882..0000000000 --- a/tests/unit/automation/scripts/test_migrate_metadata_format.py +++ /dev/null @@ -1,428 +0,0 @@ -"""Tests for automation.scripts.migrate_metadata_format module.""" - -from unittest.mock import patch - -import yaml - -from automation.scripts.migrate_metadata_format import ( - extract_title_from_header, - migrate_library_metadata, - migrate_specification_yaml, -) - - -class TestMigrateSpecificationYaml: - """Tests for migrate_specification_yaml function.""" - - def test_remove_history_add_updated(self, tmp_path): - """Test that history is removed and updated is added.""" - yaml_content = """spec_id: scatter-basic -title: Basic Scatter Plot -created: 2025-01-10T08:00:00Z -history: - - date: 2025-01-10 - action: created - - date: 2025-01-12 - action: updated -tags: - plot_type: - - scatter - domain: - - statistics -""" - yaml_file = tmp_path / "specification.yaml" - yaml_file.write_text(yaml_content) - - result = migrate_specification_yaml(yaml_file) - - assert result is True # Modified - - # Verify file content - new_content = yaml_file.read_text() - assert "history:" not in new_content - assert "updated:" in new_content - - def test_skip_already_migrated(self, tmp_path): - """Test that already migrated files are skipped.""" - yaml_content = """spec_id: scatter-basic -title: Basic Scatter Plot -created: 2025-01-10T08:00:00Z -updated: 2025-01-15T10:00:00Z -tags: - plot_type: - - scatter -""" - yaml_file = tmp_path / "specification.yaml" - yaml_file.write_text(yaml_content) - - result = migrate_specification_yaml(yaml_file) - - assert result is False # Not modified - - def test_missing_file_returns_false(self, tmp_path): - yaml_file = tmp_path / "nonexistent.yaml" - - result = migrate_specification_yaml(yaml_file) - - assert result is False - - def test_empty_file_returns_false(self, tmp_path): - yaml_file = tmp_path / "empty.yaml" - yaml_file.write_text("") - - result = migrate_specification_yaml(yaml_file) - - assert result is False - - def test_updated_uses_created_if_available(self, tmp_path): - """Test that updated is set to created value if not present.""" - yaml_content = """spec_id: test-plot -title: Test Plot -created: 2025-01-10T08:00:00Z -history: - - date: 2025-01-10 -""" - yaml_file = tmp_path / "specification.yaml" - yaml_file.write_text(yaml_content) - - migrate_specification_yaml(yaml_file) - - data = yaml.safe_load(yaml_file.read_text()) - # Updated should match created - assert "updated" in data - - -class TestMigrateLibraryMetadata: - """Tests for migrate_library_metadata function.""" - - def test_flatten_current_structure(self, tmp_path): - """Test flattening of nested current: structure.""" - yaml_content = """library: matplotlib -specification_id: scatter-basic -current: - generated_at: 2025-01-10T08:00:00Z - generated_by: claude-opus-4-5 - quality_score: 92 - python_version: "3.13" - library_version: "3.10.0" - version: 1 -history: - - version: 1 - date: 2025-01-10 -preview_url: https://example.com/plot.png -""" - yaml_file = tmp_path / "matplotlib.yaml" - yaml_file.write_text(yaml_content) - - # Patch PLOTS_DIR to use tmp_path so relative_to works - with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): - result = migrate_library_metadata(yaml_file) - - assert result is True - - # Verify flattened structure - new_data = yaml.safe_load(yaml_file.read_text()) - assert "current" not in new_data - assert "history" not in new_data - assert "version" not in new_data - assert new_data["quality_score"] == 92 - assert new_data["generated_by"] == "claude-opus-4-5" - assert new_data["python_version"] == "3.13" - - def test_adds_created_from_generated_at(self, tmp_path): - """Test that created is added from generated_at.""" - yaml_content = """library: seaborn -specification_id: scatter-basic -current: - generated_at: 2025-01-10T08:00:00Z - quality_score: 88 -""" - yaml_file = tmp_path / "seaborn.yaml" - yaml_file.write_text(yaml_content) - - with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): - migrate_library_metadata(yaml_file) - - new_data = yaml.safe_load(yaml_file.read_text()) - assert "created" in new_data - assert "updated" in new_data - - def test_adds_empty_review_section(self, tmp_path): - """Test that empty review section is added.""" - yaml_content = """library: plotly -specification_id: scatter-basic -quality_score: 90 -""" - yaml_file = tmp_path / "plotly.yaml" - yaml_file.write_text(yaml_content) - - with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): - migrate_library_metadata(yaml_file) - - new_data = yaml.safe_load(yaml_file.read_text()) - assert "review" in new_data - assert "strengths" in new_data["review"] - assert "weaknesses" in new_data["review"] - - def test_skip_already_migrated(self, tmp_path): - """Test that already migrated files are skipped.""" - yaml_content = """library: matplotlib -specification_id: scatter-basic -created: 2025-01-10T08:00:00Z -updated: 2025-01-15T10:00:00Z -quality_score: 92 -review: - strengths: [] - weaknesses: [] - improvements: [] -""" - yaml_file = tmp_path / "matplotlib.yaml" - yaml_file.write_text(yaml_content) - - result = migrate_library_metadata(yaml_file) - - # File has all required fields, should not be modified - # (unless current: or history: present) - assert result is False - - def test_missing_file_returns_false(self, tmp_path): - yaml_file = tmp_path / "nonexistent.yaml" - - result = migrate_library_metadata(yaml_file) - - assert result is False - - def test_empty_file_returns_false(self, tmp_path): - yaml_file = tmp_path / "empty.yaml" - yaml_file.write_text("") - - result = migrate_library_metadata(yaml_file) - - assert result is False - - def test_removes_version_field(self, tmp_path): - """Test that version field is removed.""" - yaml_content = """library: bokeh -specification_id: scatter-basic -version: 3 -quality_score: 85 -""" - yaml_file = tmp_path / "bokeh.yaml" - yaml_file.write_text(yaml_content) - - with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): - migrate_library_metadata(yaml_file) - - new_data = yaml.safe_load(yaml_file.read_text()) - assert "version" not in new_data - - -class TestExtractTitleFromHeader: - """Tests for extract_title_from_header function.""" - - def test_extract_standard_format(self): - header = '''""" pyplots.ai -scatter-basic: Basic Scatter Plot -Library: matplotlib 3.10.0 | Python 3.13 -Quality: 92/100 | Created: 2025-01-10 -"""''' - - result = extract_title_from_header(header) - - assert result == "Basic Scatter Plot" - - def test_extract_with_colon_in_title(self): - """Test title extraction when title contains colons.""" - header = '''""" -heatmap-correlation: Correlation Matrix: Visualizing Relationships -Library: seaborn -"""''' - - result = extract_title_from_header(header) - - # Should get everything after first colon on the spec-id line - assert "Correlation Matrix" in result - - def test_extract_simple_title(self): - header = '''""" -bar-basic: Simple Bar Chart -Library: matplotlib -"""''' - - result = extract_title_from_header(header) - - assert result == "Simple Bar Chart" - - def test_extract_empty_header(self): - result = extract_title_from_header('""""""') - - assert result == "" - - def test_extract_only_library_line(self): - """Test that header with only library line returns empty string.""" - header = '''""" -Library: matplotlib -"""''' - - result = extract_title_from_header(header) - - # Library: line is ignored, no other title line present - assert result == "" - - def test_extract_ignores_library_line(self): - """Test that Library: line is not mistaken for title.""" - header = '''""" -scatter-basic: My Plot -Library: matplotlib 3.10.0 -"""''' - - result = extract_title_from_header(header) - - assert result == "My Plot" - assert "matplotlib" not in result - - def test_extract_with_newlines(self): - header = '''""" - -scatter-3d: 3D Scatter Plot - -Library: plotly 5.18.0 -"""''' - - result = extract_title_from_header(header) - - assert result == "3D Scatter Plot" - - def test_extract_multiword_title(self): - header = '''""" -violin-grouped: Grouped Violin Plot with Multiple Categories -Library: seaborn -"""''' - - result = extract_title_from_header(header) - - assert result == "Grouped Violin Plot with Multiple Categories" - - -class TestMigratePlot: - """Integration tests for migrate_plot function.""" - - def test_migrate_complete_plot_directory(self, tmp_path): - """Test migration of a complete plot directory.""" - from automation.scripts.migrate_metadata_format import migrate_plot - - # Create plot directory structure - plot_dir = tmp_path / "scatter-basic" - plot_dir.mkdir() - - # specification.yaml with history - (plot_dir / "specification.yaml").write_text("""spec_id: scatter-basic -title: Basic Scatter Plot -created: 2025-01-10T08:00:00Z -history: - - date: 2025-01-10 -tags: - plot_type: - - scatter -""") - - # metadata/ directory - meta_dir = plot_dir / "metadata" - meta_dir.mkdir() - - # metadata/matplotlib.yaml with current: structure - (meta_dir / "matplotlib.yaml").write_text("""library: matplotlib -specification_id: scatter-basic -current: - generated_at: 2025-01-10T08:00:00Z - quality_score: 92 - python_version: "3.13" - library_version: "3.10.0" -preview_url: https://example.com/plot.png -""") - - # implementations/ directory - impl_dir = plot_dir / "implementations" - impl_dir.mkdir() - - # implementations/matplotlib.py - (impl_dir / "matplotlib.py").write_text('''""" -scatter-basic: Basic Scatter Plot -Library: matplotlib -""" -import matplotlib.pyplot as plt -plt.plot([1, 2, 3]) -''') - - # Run migration with patched PLOTS_DIR - with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): - stats = migrate_plot(plot_dir) - - assert stats["spec"] == 1 # specification.yaml migrated - assert stats["meta"] == 1 # matplotlib.yaml migrated - - # Verify specification.yaml - spec_data = yaml.safe_load((plot_dir / "specification.yaml").read_text()) - assert "history" not in spec_data - assert "updated" in spec_data - - # Verify matplotlib.yaml - meta_data = yaml.safe_load((meta_dir / "matplotlib.yaml").read_text()) - assert "current" not in meta_data - assert meta_data["quality_score"] == 92 - assert "review" in meta_data - - def test_migrate_skips_underscore_files(self, tmp_path): - """Test that files starting with underscore are skipped.""" - from automation.scripts.migrate_metadata_format import migrate_plot - - plot_dir = tmp_path / "test-plot" - plot_dir.mkdir() - - (plot_dir / "specification.yaml").write_text("""spec_id: test-plot -title: Test -created: 2025-01-10T08:00:00Z -updated: 2025-01-10T08:00:00Z -tags: - plot_type: - - test -""") - - impl_dir = plot_dir / "implementations" - impl_dir.mkdir() - - # Use a header that won't be migrated (already correct format) - # Note: library_version must match metadata for header to be identical - (impl_dir / "matplotlib.py").write_text('''""" pyplots.ai -test-plot: Test -Library: matplotlib 3.10.0 | Python 3.13 -Quality: 92/100 | Created: 2025-01-10 -""" -import matplotlib.pyplot as plt -''') - (impl_dir / "_template.py").write_text('"""template - should be skipped"""') - - meta_dir = plot_dir / "metadata" - meta_dir.mkdir() - - (meta_dir / "matplotlib.yaml").write_text("""library: matplotlib -specification_id: test-plot -created: 2025-01-10T08:00:00Z -updated: 2025-01-10T08:00:00Z -python_version: "3.13" -library_version: "3.10.0" -quality_score: 92 -review: - strengths: [] - weaknesses: [] - improvements: [] -""") - - with patch("automation.scripts.migrate_metadata_format.PLOTS_DIR", tmp_path): - stats = migrate_plot(plot_dir) - - # _template.py should be skipped (only matplotlib.py processed) - # matplotlib.py header is already in correct format, so no migration needed - assert stats["impl"] == 0 - # Verify _template.py was not modified - assert (impl_dir / "_template.py").read_text() == '"""template - should be skipped"""' From a995264b2081cd73304781cbbc92b4ae8bcea16a Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:45:35 +0100 Subject: [PATCH 3/3] test(api): extend router tests for 100% coverage on download and libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for: - Download success with mocked httpx response - Download GCS error handling (502 response) - Libraries cache hit - Library images with database - Library images cache hit Coverage improvements: - api/routers/download.py: 68% → 100% - api/routers/libraries.py: 62% → 100% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/unit/api/test_routers.py | 90 ++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py index 8cb3b5a7d5..43fd47039b 100644 --- a/tests/unit/api/test_routers.py +++ b/tests/unit/api/test_routers.py @@ -201,6 +201,49 @@ def test_libraries_with_db(self, db_client, mock_lib) -> None: assert len(data["libraries"]) == 1 assert data["libraries"][0]["id"] == "matplotlib" + def test_libraries_cache_hit(self, db_client) -> None: + """Libraries should return cached data when available.""" + client, _ = db_client + + cached_data = {"libraries": [{"id": "cached_lib", "name": "Cached"}]} + + with patch("api.routers.libraries.get_cache", return_value=cached_data): + response = client.get("/libraries") + assert response.status_code == 200 + data = response.json() + assert data["libraries"][0]["id"] == "cached_lib" + + def test_library_images_with_db(self, db_client, mock_spec) -> None: + """Library images should return images from DB.""" + client, _ = db_client + + mock_spec_repo = MagicMock() + mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec]) + + with ( + patch("api.routers.libraries.get_cache", return_value=None), + patch("api.routers.libraries.set_cache"), + patch("api.routers.libraries.SpecRepository", return_value=mock_spec_repo), + ): + response = client.get("/libraries/matplotlib/images") + assert response.status_code == 200 + data = response.json() + assert data["library"] == "matplotlib" + assert len(data["images"]) == 1 + assert data["images"][0]["spec_id"] == "scatter-basic" + + def test_library_images_cache_hit(self, db_client) -> None: + """Library images should return cached data when available.""" + client, _ = db_client + + cached_data = {"library": "matplotlib", "images": [{"spec_id": "cached"}]} + + with patch("api.routers.libraries.get_cache", return_value=cached_data): + response = client.get("/libraries/matplotlib/images") + assert response.status_code == 200 + data = response.json() + assert data["images"][0]["spec_id"] == "cached" + class TestSpecsRouter: """Tests for specs router.""" @@ -305,6 +348,53 @@ def test_download_impl_not_found(self, client: TestClient, mock_spec) -> None: response = client.get("/download/scatter-basic/seaborn") assert response.status_code == 404 + def test_download_success(self, client: TestClient, mock_spec) -> None: + """Download should return image when spec and impl found.""" + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) + + # Mock httpx response + mock_response = MagicMock() + mock_response.content = b"fake image content" + mock_response.raise_for_status = MagicMock() + + mock_httpx_client = AsyncMock() + mock_httpx_client.get = AsyncMock(return_value=mock_response) + mock_httpx_client.__aenter__ = AsyncMock(return_value=mock_httpx_client) + mock_httpx_client.__aexit__ = AsyncMock(return_value=None) + + with ( + patch(DB_CONFIG_PATCH, return_value=True), + patch("api.routers.download.SpecRepository", return_value=mock_spec_repo), + patch("api.routers.download.httpx.AsyncClient", return_value=mock_httpx_client), + ): + response = client.get("/download/scatter-basic/matplotlib") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert "attachment" in response.headers["content-disposition"] + assert response.content == b"fake image content" + + def test_download_gcs_error(self, client: TestClient, mock_spec) -> None: + """Download should return 502 when GCS fetch fails.""" + import httpx + + mock_spec_repo = MagicMock() + mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) + + mock_httpx_client = AsyncMock() + mock_httpx_client.get = AsyncMock(side_effect=httpx.HTTPError("GCS error")) + mock_httpx_client.__aenter__ = AsyncMock(return_value=mock_httpx_client) + mock_httpx_client.__aexit__ = AsyncMock(return_value=None) + + with ( + patch(DB_CONFIG_PATCH, return_value=True), + patch("api.routers.download.SpecRepository", return_value=mock_spec_repo), + patch("api.routers.download.httpx.AsyncClient", return_value=mock_httpx_client), + ): + response = client.get("/download/scatter-basic/matplotlib") + assert response.status_code == 502 + class TestSeoRouter: """Tests for SEO router."""