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