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.""" 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_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"