7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
211 lines
7.9 KiB
Python
211 lines
7.9 KiB
Python
"""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
|
||
)
|