from __future__ import annotations import pytest from app.services.portability.constants import MAX_IMPORT_PUBLICATIONS, MAX_IMPORT_SCHOLARS from app.services.portability.normalize import ( _build_fingerprint, _first_author_last_name, _first_venue_word, _normalize_citation_count, _normalize_optional_text, _normalize_optional_year, _resolve_fingerprint, _validate_import_sizes, ) from app.services.portability.types import ImportExportError class TestNormalizeOptionalText: def test_returns_stripped_string(self) -> None: assert _normalize_optional_text(" hello ") == "hello" def test_returns_none_for_none(self) -> None: assert _normalize_optional_text(None) is None def test_returns_none_for_empty_string(self) -> None: assert _normalize_optional_text("") is None def test_returns_none_for_whitespace_only(self) -> None: assert _normalize_optional_text(" ") is None def test_coerces_non_string_to_string(self) -> None: assert _normalize_optional_text(42) == "42" class TestNormalizeOptionalYear: def test_valid_year(self) -> None: assert _normalize_optional_year(2024) == 2024 def test_string_year(self) -> None: assert _normalize_optional_year("1999") == 1999 def test_returns_none_for_none(self) -> None: assert _normalize_optional_year(None) is None def test_returns_none_for_non_numeric(self) -> None: assert _normalize_optional_year("abc") is None def test_returns_none_for_year_below_1500(self) -> None: assert _normalize_optional_year(1499) is None def test_returns_none_for_year_above_3000(self) -> None: assert _normalize_optional_year(3001) is None def test_boundary_1500_accepted(self) -> None: assert _normalize_optional_year(1500) == 1500 def test_boundary_3000_accepted(self) -> None: assert _normalize_optional_year(3000) == 3000 class TestNormalizeCitationCount: def test_valid_positive(self) -> None: assert _normalize_citation_count(10) == 10 def test_zero(self) -> None: assert _normalize_citation_count(0) == 0 def test_negative_clamped_to_zero(self) -> None: assert _normalize_citation_count(-5) == 0 def test_string_number(self) -> None: assert _normalize_citation_count("42") == 42 def test_none_returns_zero(self) -> None: assert _normalize_citation_count(None) == 0 def test_invalid_string_returns_zero(self) -> None: assert _normalize_citation_count("abc") == 0 class TestFirstAuthorLastName: def test_single_author(self) -> None: assert _first_author_last_name("John Smith") == "smith" def test_multiple_authors(self) -> None: assert _first_author_last_name("Jane Doe, John Smith") == "doe" def test_empty_string(self) -> None: assert _first_author_last_name("") == "" def test_none(self) -> None: assert _first_author_last_name(None) == "" class TestFirstVenueWord: def test_returns_first_word(self) -> None: assert _first_venue_word("Nature Communications") == "nature" def test_empty_string(self) -> None: assert _first_venue_word("") == "" def test_none(self) -> None: assert _first_venue_word(None) == "" class TestBuildFingerprint: def test_produces_64_hex_digest(self) -> None: fp = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML") assert len(fp) == 64 assert all(c in "0123456789abcdef" for c in fp) def test_deterministic(self) -> None: fp_a = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML") fp_b = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML") assert fp_a == fp_b def test_different_titles_produce_different_fingerprints(self) -> None: fp1 = _build_fingerprint(title="Title A", year=2024, author_text=None, venue_text=None) fp2 = _build_fingerprint(title="Title B", year=2024, author_text=None, venue_text=None) assert fp1 != fp2 def test_none_year_handled(self) -> None: fp = _build_fingerprint(title="Test", year=None, author_text=None, venue_text=None) assert len(fp) == 64 class TestResolveFingerprint: def test_uses_provided_valid_sha256(self) -> None: valid_sha = "a" * 64 result = _resolve_fingerprint( title="Test", year=2024, author_text=None, venue_text=None, provided_fingerprint=valid_sha, ) assert result == valid_sha def test_computes_fingerprint_when_provided_is_none(self) -> None: result = _resolve_fingerprint( title="Test", year=2024, author_text=None, venue_text=None, provided_fingerprint=None, ) assert len(result) == 64 def test_computes_fingerprint_when_provided_is_invalid(self) -> None: result = _resolve_fingerprint( title="Test", year=2024, author_text=None, venue_text=None, provided_fingerprint="not-a-sha", ) assert len(result) == 64 def test_normalizes_provided_to_lowercase(self) -> None: valid_sha = "A" * 64 result = _resolve_fingerprint( title="Test", year=2024, author_text=None, venue_text=None, provided_fingerprint=valid_sha, ) assert result == "a" * 64 class TestValidateImportSizes: def test_accepts_within_limits(self) -> None: _validate_import_sizes(scholars=[{}], publications=[{}]) def test_rejects_too_many_scholars(self) -> None: with pytest.raises(ImportExportError, match="max scholars"): _validate_import_sizes( scholars=[{}] * (MAX_IMPORT_SCHOLARS + 1), publications=[], ) def test_rejects_too_many_publications(self) -> None: with pytest.raises(ImportExportError, match="max publications"): _validate_import_sizes( scholars=[], publications=[{}] * (MAX_IMPORT_PUBLICATIONS + 1), ) def test_accepts_exact_limit(self) -> None: _validate_import_sizes( scholars=[{}] * MAX_IMPORT_SCHOLARS, publications=[{}] * MAX_IMPORT_PUBLICATIONS, )