| 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 |
|
|