GCMD_Keyword_Classifier_MVP / tests /test_articles.py
igerasimov's picture
Fixed smoke tests
08942f5
Raw
History Blame Contribute Delete
7.92 kB
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