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 @pytest.mark.parametrize( ("record", "field"), [ ({"Title": "T", "Year": 2024, "Abstract": "A"}, "DOI"), ({"DOI": "10.x", "Year": 2024, "Abstract": "A"}, "Title"), ({"DOI": "10.x", "Title": "T", "Abstract": "A"}, "Year"), ({"DOI": "10.x", "Title": "T", "Year": 2024}, "Abstract"), ], ) 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) @pytest.mark.parametrize( ("record", "field"), [ ({"DOI": "", "Title": "T", "Year": 2024, "Abstract": "A"}, "DOI"), ({"DOI": "10.x", "Title": "", "Year": 2024, "Abstract": "A"}, "Title"), ], ) 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 == " " @pytest.mark.parametrize( ("field", "value"), [ ("DOI", 123), ("Title", ["bad"]), ("Abstract", 42), ], ) 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) @pytest.mark.parametrize("year", ["2024", 2024.0, True, False]) 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