"""Unit tests for the pure-function scoring helpers in ``journal_quality.scoring``. These cover the rules baked into ``derive_quality_score`` and ``institution_score_from_h_index``. They live in their own file so the scoring rubric is documented and locked in independently of the DB build pipeline. """ import pytest from local_deep_research.journal_quality.scoring import ( derive_quality_score, institution_score_from_h_index, normalize_name, ) class TestDeriveQualityScore: """The 1–10 quality score rubric.""" def test_predatory_not_in_doaj_returns_one(self): # Auto-remove threshold — caller treats score 1 as "drop this". assert derive_quality_score(is_predatory=True, is_in_doaj=False) == 1 def test_predatory_in_doaj_falls_through(self): # DOAJ membership rescues the journal — predatory flag is ignored # because the whitelist override at build time clears it. # `derive_quality_score` itself only applies the auto-remove rule # when NOT in DOAJ; otherwise it uses the normal scoring. assert derive_quality_score(is_predatory=True, is_in_doaj=True) == 5 @pytest.mark.parametrize( "h_index,expected", [ (151, 10), # Elite (Nature/Science/NEJM) (76, 8), (41, 7), (21, 6), (11, 5), (1, 4), (10, 4), # 10 is NOT > 10 → falls into the "default" branch ], ) def test_h_index_thresholds(self, h_index, expected): assert derive_quality_score(h_index=h_index) == expected def test_doaj_listed_returns_five(self): # There used to be a DOAJ Seal tier (score 8) above this; DOAJ # retired the Seal in April 2025 so listing is the only DOAJ # signal now. assert derive_quality_score(is_in_doaj=True) == 5 def test_h_index_takes_precedence_over_doaj(self): # If we have an h-index, it wins over DOAJ — h-index is the # canonical bibliometric and DOAJ is the OA fallback. assert ( derive_quality_score(h_index=80, is_in_doaj=True) == 8 # h_index branch returns 8 (> 75) ) def test_conference_default(self): assert derive_quality_score(source_type="conference") == 5 def test_repository_capped_at_acceptable(self): """Preprint repositories (arXiv, bioRxiv, ...) are not peer- reviewed — their h-index reflects aggregate paper citations, not venue rigor. arXiv has h=674 + Q1 from OpenAlex but must NOT rank as ELITE/Q1 in our scoring. """ # arXiv's real OpenAlex stats — should NOT produce 10. assert ( derive_quality_score( h_index=674, quartile="Q1", source_type="repository", ) == 5 ) # Predatory check still fires first for repositories flagged # predatory (unlikely but defensive). assert ( derive_quality_score( is_predatory=True, source_type="repository", ) == 1 ) def test_returns_none_when_no_signal(self): # No h-index, not in DOAJ, not a conference → caller should # treat as "unknown" and fall through to the next tier. assert derive_quality_score() is None assert derive_quality_score(h_index=0) is None def test_h_index_zero_treated_as_no_signal(self): # h-index=0 means "newly indexed, not meaningful" — should NOT # trigger the h-index branch. assert ( derive_quality_score(h_index=0, is_in_doaj=True) == 5 # falls through to DOAJ-listed scoring ) class TestInstitutionScoreFromHIndex: """Single source of truth for institution h-index → score mapping. `db.score_from_affiliations` delegates here, so this also covers the consolidation of the previously-duplicated scoring path in db.py. """ @pytest.mark.parametrize( "h_index,expected", [ (None, None), (0, 4), # below threshold → default (49, 4), (50, 4), # NOT > 50 → default (51, 5), (100, 5), # the historically duplicated branch — confirmed merged (250, 5), # NOT > 250 → still 5 (251, 6), # top tier (1000, 6), # cap at 6 — institution alone never beats venue ], ) def test_thresholds(self, h_index, expected): assert institution_score_from_h_index(h_index) == expected class TestNormalizeName: """Helper used by the build pipeline + the runtime accessor.""" def test_lowercases(self): assert normalize_name("Nature") == "nature" def test_strips_whitespace(self): assert normalize_name(" Nature ") == "nature" def test_nfkc_normalization(self): # Half-width and full-width characters should compare equal. assert normalize_name("NATURE") == normalize_name("NATURE") class TestDeriveQualityScoreQuartile: """Tests for the new ``quartile`` parameter on ``derive_quality_score``. Quartile is the canonical librarian-facing journal quality signal. These tests document the rubric: Q1 → strong (or elite if h_index also tops the elite threshold), Q2 → very_good, Q3 → good, Q4 → acceptable. Predatory always wins. Quartile takes precedence over DOAJ but defers to the predatory check. """ @pytest.mark.parametrize( "quartile,expected", [ ("Q1", 8), # JOURNAL_QUALITY_STRONG ("Q2", 7), # JOURNAL_QUALITY_VERY_GOOD ("Q3", 6), # JOURNAL_QUALITY_GOOD ("Q4", 5), # JOURNAL_QUALITY_ACCEPTABLE ], ) def test_quartile_maps_to_score(self, quartile, expected): assert derive_quality_score(quartile=quartile) == expected def test_q1_with_elite_h_index_returns_elite(self): # Q1 + h-index above the elite threshold (150) should bump to 10 # so Nature stays distinguishable from a typical Q1. assert derive_quality_score(quartile="Q1", h_index=200) == 10 def test_q1_with_low_h_index_stays_strong(self): # h-index below the elite threshold doesn't downgrade Q1. assert derive_quality_score(quartile="Q1", h_index=50) == 8 @pytest.mark.parametrize("quartile", ["q1", " Q1 ", "Q1\n", "q2"]) def test_quartile_is_normalized(self, quartile): # Lowercase / trailing whitespace / odd casing should still match. # We're verifying `derive_quality_score` does the .upper().strip() # itself rather than relying on the caller. result = derive_quality_score(quartile=quartile) assert result is not None assert 5 <= result <= 10 # All Q* should map to a valid score def test_unknown_quartile_falls_through_to_h_index(self): # Garbage quartile input should NOT silently use the h-index; # it should fall through to the next branch (h_index→default). # Tests document the safety: a typo in quartile data doesn't # corrupt the score. assert ( derive_quality_score(quartile="ZZ", h_index=200) == 10 ) # Falls through to h_index branch assert ( derive_quality_score(quartile="ZZ") is None ) # No fallback signal at all def test_quartile_takes_precedence_over_doaj(self): # When both quartile and DOAJ are present, quartile wins (it's # a stronger signal than just being in DOAJ). # Q4 (5) is the same value as DOAJ_QUALITY_LISTED (5), so use # Q3 (6) which is distinct from the DOAJ-listed score. assert derive_quality_score(quartile="Q3", is_in_doaj=True) == 6 def test_predatory_overrides_quartile(self): # Even a Q1 journal gets dropped if predatory and not in DOAJ. assert ( derive_quality_score( quartile="Q1", is_predatory=True, is_in_doaj=False ) == 1 )