Spaces:
Running on Zero
Running on Zero
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| import pytest | |
| from gcmd_classifier.articles import ( | |
| load_articles, | |
| read_article_json, | |
| validate_article_record, | |
| validate_article_records, | |
| ) | |
| from gcmd_classifier.errors import ( | |
| ArticleFileNotFoundError, | |
| ArticleJSONDecodeError, | |
| ArticleTopLevelTypeError, | |
| ) | |
| from gcmd_classifier.models import ArticleRecord | |
| VALID_FIXTURE = Path("tests/fixtures/articles_valid.json") | |
| INVALID_FIXTURE = Path("tests/fixtures/articles_invalid.json") | |
| FULL_ARTICLES_PATH = Path("data/articles.json") | |
| def test_valid_article_loading() -> None: | |
| result = load_articles(VALID_FIXTURE) | |
| assert result.source_count == 3 | |
| assert result.valid_count == 3 | |
| assert result.errors == () | |
| assert all(isinstance(article, ArticleRecord) for article in result.articles) | |
| def test_preserves_source_order() -> None: | |
| result = load_articles(VALID_FIXTURE) | |
| assert [article.DOI for article in result.articles] == [ | |
| "10.0001/ATMOS.TEST", | |
| "10.0002/MULTI.TEST", | |
| "10.0003/TITLEONLY.TEST", | |
| ] | |
| def test_exact_source_value_preservation() -> None: | |
| raw = json.loads(VALID_FIXTURE.read_text()) | |
| result = load_articles(VALID_FIXTURE) | |
| first = result.articles[0] | |
| assert first.DOI == raw[0]["DOI"] | |
| assert first.Title == raw[0]["Title"] | |
| assert first.Year == raw[0]["Year"] | |
| assert first.Abstract == raw[0]["Abstract"] | |
| assert first.Title.startswith(" ") | |
| assert first.Title.endswith(" ") | |
| def test_empty_abstract_is_valid_and_preserved() -> None: | |
| result = load_articles(VALID_FIXTURE) | |
| article = result.articles[2] | |
| assert article.Abstract == "" | |
| assert article.Title == "Editorial: New instrumentation for environmental archives?" | |
| def test_exact_serialization_field_names() -> None: | |
| article = load_articles(VALID_FIXTURE).articles[0] | |
| assert article.model_dump() == { | |
| "DOI": "10.0001/ATMOS.TEST", | |
| "Title": " High-Resolution Rainfall Trends over Mountain Basins: A 20-Year Study ", | |
| "Year": 2024, | |
| "Abstract": "Precipitation intensity, snowpack, and runoff timing are evaluated using " | |
| "station and satellite observations.", | |
| } | |
| def test_invalid_fixture_reports_multiple_record_errors() -> None: | |
| result = load_articles(INVALID_FIXTURE) | |
| codes = [error.code for error in result.errors] | |
| assert result.source_count == 18 | |
| assert result.has_errors is True | |
| assert "MISSING_REQUIRED_FIELD" in codes | |
| assert "EMPTY_REQUIRED_STRING" in codes | |
| assert "INVALID_FIELD_TYPE" in codes | |
| assert "ARTICLE_NOT_OBJECT" in codes | |
| assert "DUPLICATE_DOI" in codes | |
| assert "UNEXPECTED_FIELD" in codes | |
| assert len(result.errors) >= 17 | |
| def test_missing_required_fields(record: dict, field: str) -> None: | |
| article, errors = validate_article_record(record, index=7) | |
| assert article is None | |
| assert any(error.code == "MISSING_REQUIRED_FIELD" and error.field == field for error in errors) | |
| assert all(error.index == 7 for error in errors) | |
| def test_empty_required_strings(record: dict, field: str) -> None: | |
| article, errors = validate_article_record(record, index=1) | |
| assert article is None | |
| assert any(error.code == "EMPTY_REQUIRED_STRING" and error.field == field for error in errors) | |
| def test_whitespace_only_title_is_preserved_not_normalized() -> None: | |
| record = {"DOI": "10.x/space", "Title": " ", "Year": 2024, "Abstract": "A"} | |
| article, errors = validate_article_record(record, index=0) | |
| assert errors == () | |
| assert article is not None | |
| assert article.Title == " " | |
| def test_non_string_fields_rejected(field: str, value: object) -> None: | |
| record = {"DOI": "10.x", "Title": "T", "Year": 2024, "Abstract": "A"} | |
| record[field] = value | |
| article, errors = validate_article_record(record, index=2) | |
| assert article is None | |
| assert any(error.code == "INVALID_FIELD_TYPE" and error.field == field for error in errors) | |
| def test_invalid_year_types_rejected(year: object) -> None: | |
| record = {"DOI": "10.x/year", "Title": "T", "Year": year, "Abstract": "A"} | |
| article, errors = validate_article_record(record, index=3) | |
| assert article is None | |
| assert any(error.code == "INVALID_FIELD_TYPE" and error.field == "Year" for error in errors) | |
| def test_null_field_values_rejected() -> None: | |
| record = {"DOI": None, "Title": None, "Year": None, "Abstract": None} | |
| article, errors = validate_article_record(record, index=4) | |
| assert article is None | |
| assert len([error for error in errors if error.code == "INVALID_FIELD_TYPE"]) == 4 | |
| def test_duplicate_doi_detection_excludes_duplicate_records_from_valid_records() -> None: | |
| raw_records = [ | |
| {"DOI": "10.same", "Title": "One", "Year": 2024, "Abstract": "A"}, | |
| {"DOI": "10.same", "Title": "Two", "Year": 2025, "Abstract": "B"}, | |
| {"DOI": "10.unique", "Title": "Three", "Year": 2026, "Abstract": "C"}, | |
| ] | |
| result = validate_article_records(raw_records) | |
| assert [article.DOI for article in result.articles] == ["10.unique"] | |
| assert [error.index for error in result.errors if error.code == "DUPLICATE_DOI"] == [0, 1] | |
| def test_non_object_article_entry() -> None: | |
| result = validate_article_records(["bad"]) | |
| assert result.articles == () | |
| assert result.errors[0].code == "ARTICLE_NOT_OBJECT" | |
| assert result.errors[0].index == 0 | |
| def test_top_level_non_list_json() -> None: | |
| with pytest.raises(ArticleTopLevelTypeError): | |
| validate_article_records({"DOI": "10.x"}) | |
| def test_invalid_json(tmp_path: Path) -> None: | |
| path = tmp_path / "invalid.json" | |
| path.write_text("[{bad json]") | |
| with pytest.raises(ArticleJSONDecodeError): | |
| read_article_json(path) | |
| def test_file_not_found_behavior(tmp_path: Path) -> None: | |
| with pytest.raises(ArticleFileNotFoundError): | |
| load_articles(tmp_path / "missing.json") | |
| def test_source_file_immutability() -> None: | |
| before = FULL_ARTICLES_PATH.read_bytes() | |
| load_articles(FULL_ARTICLES_PATH) | |
| after = FULL_ARTICLES_PATH.read_bytes() | |
| assert after == before | |
| def test_full_data_smoke_current_articles_file() -> None: | |
| raw_records = json.loads(FULL_ARTICLES_PATH.read_text()) | |
| result = load_articles(FULL_ARTICLES_PATH) | |
| assert isinstance(raw_records, list) | |
| assert result.source_count == len(raw_records) | |
| assert result.valid_count == len(raw_records) - len(result.errors) | |
| assert len({article.DOI for article in result.articles}) == result.valid_count | |
| if result.errors: | |
| assert result.has_errors is True | |
| assert all(error.code and error.message for error in result.errors) | |
| assert all( | |
| error.index is None or 0 <= error.index < len(raw_records) for error in result.errors | |
| ) | |
| else: | |
| assert result.has_errors is False | |
| invalid_indices = {error.index for error in result.errors if error.index is not None} | |
| valid_raw_records = [ | |
| record for index, record in enumerate(raw_records) if index not in invalid_indices | |
| ] | |
| assert [article.model_dump() for article in result.articles] == valid_raw_records | |