File size: 7,917 Bytes
413d5a1 08942f5 413d5a1 08942f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | 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
|