- Bug-R19-1 (HIGH): NOT (A OR B)与(NOT A OR NOT B)结构一致 → group_negated - Bug-R19-2 (MEDIUM): 日期swap year:full_date边界过宽 → year branch恢复> - Bug-R19-3 (MEDIUM): group_negated长度与groups不匹配 → 补append(False) - Bug-R19-4 (LOW): 括号内AND聚类丢失 → 移除_depth>0守卫 - Bug-R19-5 (MEDIUM): 混合大小写引号短语丢失exact标记 → term.lower() - Bug-R19-6 (HIGH): 公共搜索完全不可用 → 移除get_current_user依赖 - Bug-R19-7 (MEDIUM): message.warning()副作用在Vue computed中 → 移除 - Bug-R19-8 (MEDIUM): 搜索错误信息不区分状态码 → 401/400/429/500 - Bug-R19-9 (HIGH): R18 text重命名遗留未引用 → _term_text完全化
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""Comprehensive verification: covers uncovered gaps from Group A+B test matrices.
|
|
|
|
Existing test_pubmed_search_integration.py covers ~35 scenarios.
|
|
This script adds coverage for uncovered items:
|
|
|
|
Group A gaps:
|
|
A1c "lung cancer" (quoted plain phrase)
|
|
A1d cancer* (wildcard in parser)
|
|
A1e 12345 (numeric plain)
|
|
A2b cancer[TI] lung[AB] (cross-field two fields)
|
|
A2f asthma[MH:noexp]
|
|
A2g cancer[MESH]
|
|
A2i D000001[PA]
|
|
A2j pubmed[SB]
|
|
A2k medline[STAT]
|
|
A2l 12345[UID]
|
|
A2m 10.1000/xyz[UID]
|
|
A2p Nature[JT]
|
|
A3c EDAT date range
|
|
A3d DEP date range
|
|
A3e NOT date range (negated_date_ranges)
|
|
A4e double paren ((a OR b) AND c)
|
|
A4f boolean precedence OR AND
|
|
A5 series (mixed queries with ATM implications)
|
|
A6c over 100 tokens
|
|
|
|
Group B gaps:
|
|
B4 paren groups SQL condition verification
|
|
B7 MH:noexp → no tree expansion
|
|
B8 SB subset SQL
|
|
B9 STAT citation_status SQL
|
|
B12 DEP date SQL
|
|
B13 eng[LA] exact match
|
|
"""
|
|
|
|
import pytest
|
|
from app.services.pubmed_query_parser import parse_pubmed_query, is_pubmed_syntax, ParsedPubmedQuery, Term
|
|
from app.services.pubmed_query_parser import _ALL_FIELD_TAGS, _FIELD_TAG_MAP, _SPECIAL_FIELDS
|
|
|
|
|
|
class TestParserGaps:
|
|
"""Cover Group A gaps not in existing suite."""
|
|
|
|
# ── A1: plain text ──
|
|
|
|
def test_a1c_quoted_plain_phrase(self):
|
|
""""lung cancer" — single quoted plain term"""
|
|
r = parse_pubmed_query('"lung cancer"')
|
|
assert len(r.plain_terms) == 1
|
|
assert r.plain_terms[0].text == "lung cancer"
|
|
assert r.plain_terms[0].exact is True
|
|
|
|
def test_a1d_plain_wildcard(self):
|
|
"""cancer* — wildcard in plain term"""
|
|
r = parse_pubmed_query("cancer*")
|
|
assert len(r.plain_terms) == 1
|
|
assert r.plain_terms[0].text == "cancer*"
|
|
|
|
def test_a1e_numeric_plain(self):
|
|
"""12345 — numeric plain term"""
|
|
r = parse_pubmed_query("12345")
|
|
assert len(r.plain_terms) == 1
|
|
assert r.plain_terms[0].text == "12345"
|
|
|
|
def test_a1f_plain_three_words(self):
|
|
"""lung cancer immunotherapy — three plain terms AND"""
|
|
r = parse_pubmed_query("lung cancer immunotherapy")
|
|
assert len(r.plain_terms) == 3
|
|
texts = [t.text for t in r.plain_terms]
|
|
assert "lung" in texts and "cancer" in texts and "immunotherapy" in texts
|
|
assert r.boolean_operator == "and"
|
|
|
|
# ── A2: field tags gaps ──
|
|
|
|
def test_a2b_cross_field_two_fields(self):
|
|
"""cancer[TI] lung[AB] — two different field terms"""
|
|
r = parse_pubmed_query("cancer[TI] lung[AB]")
|
|
assert len(r.title_terms) == 1
|
|
assert r.title_terms[0].text == "cancer"
|
|
assert len(r.abstract_terms) == 1
|
|
assert r.abstract_terms[0].text == "lung"
|
|
|
|
def test_a2f_mh_noexp(self):
|
|
"""asthma[MH:noexp] — no expansion flag"""
|
|
r = parse_pubmed_query("asthma[MH:noexp]")
|
|
assert len(r.mesh_terms) == 1
|
|
assert r.mesh_terms[0].text == "asthma"
|
|
assert r.mesh_terms[0]._noexp is True
|
|
|
|
def test_a2g_mesh_alias(self):
|
|
"""cancer[MESH] — MESH alias → MH"""
|
|
r = parse_pubmed_query("cancer[MESH]")
|
|
assert len(r.mesh_terms) == 1
|
|
assert r.mesh_terms[0].text == "cancer"
|
|
|
|
def test_a2i_pa_pharmacological_action(self):
|
|
"""D000001[PA] — PA field"""
|
|
r = parse_pubmed_query("D000001[PA]")
|
|
assert len(r.pharmaco_terms) == 1
|
|
assert r.pharmaco_terms[0].text == "D000001"
|
|
|
|
def test_a2j_sb_subset(self):
|
|
"""pubmed[SB] — SB subset field"""
|
|
r = parse_pubmed_query("pubmed[SB]")
|
|
assert len(r.sb_terms) == 1
|
|
assert r.sb_terms[0].text == "pubmed"
|
|
|
|
def test_a2k_stat_status(self):
|
|
"""medline[STAT] — STAT status field"""
|
|
r = parse_pubmed_query("medline[STAT]")
|
|
assert len(r.stat_terms) == 1
|
|
assert r.stat_terms[0].text == "medline"
|
|
|
|
def test_a2l_uid_pmid(self):
|
|
"""12345[UID] — UID field (PMID)"""
|
|
r = parse_pubmed_query("12345[UID]")
|
|
assert len(r.uid_terms) == 1
|
|
assert r.uid_terms[0].text == "12345"
|
|
|
|
def test_a2m_uid_doi(self):
|
|
""""10.1000/xyz"[UID] — UID field (DOI)"""
|
|
r = parse_pubmed_query('"10.1000/xyz"[UID]')
|
|
assert len(r.uid_terms) == 1
|
|
assert r.uid_terms[0].text == "10.1000/xyz"
|
|
|
|
def test_a2p_jt_journal_title(self):
|
|
"""Nature[JT] — JT maps to journal"""
|
|
r = parse_pubmed_query("Nature[JT]")
|
|
assert len(r.journal_terms) == 1
|
|
assert r.journal_terms[0].text == "Nature"
|
|
|
|
def test_a2p2_jt_lowercase(self):
|
|
"""nature[JT] — JT with lowercase"""
|
|
r = parse_pubmed_query("nature[JT]")
|
|
assert len(r.journal_terms) == 1
|
|
assert r.journal_terms[0].text == "nature"
|
|
|
|
# ── A2: field tag edge cases ──
|
|
|
|
def test_a2_uid_not_quoted(self):
|
|
"""12345[UID] without quotes"""
|
|
r = parse_pubmed_query("12345[UID]")
|
|
assert len(r.uid_terms) == 1
|
|
assert r.uid_terms[0].exact is False
|
|
|
|
def test_a2_uid_lowercase(self):
|
|
"""12345[uid] — case insensitive tag"""
|
|
r = parse_pubmed_query("12345[uid]")
|
|
assert len(r.uid_terms) == 1
|
|
|
|
# ── A3: date range gaps ──
|
|
|
|
def test_a3c_edat_date_range(self):
|
|
"""2024:2025[EDAT] — EDAT year range (normalized to full date)"""
|
|
r = parse_pubmed_query("2024:2025[EDAT]")
|
|
# Parser normalizes year-only to YYYY-MM-DD
|
|
assert r.edat_from == "2024-01-01"
|
|
assert r.edat_to == "2025-12-31"
|
|
|
|
def test_a3d_dep_date_range(self):
|
|
"""2024:2025[DEP] — DEP year range (normalized)"""
|
|
r = parse_pubmed_query("2024:2025[DEP]")
|
|
# Parser normalizes year-only to YYYY-MM-DD
|
|
assert r.dep_from == "2024-01-01"
|
|
assert r.dep_to == "2025-12-31"
|
|
|
|
def test_a3d_dep_full_date(self):
|
|
"""2024-01-01:2024-12-31[DEP] — DEP full date range"""
|
|
r = parse_pubmed_query("2024-01-01:2024-12-31[DEP]")
|
|
assert r.dep_from == "2024-01-01"
|
|
assert r.dep_to == "2024-12-31"
|
|
|
|
def test_a3e_not_date_range(self):
|
|
"""cancer NOT 2020:2024[DP] — negated date range"""
|
|
r = parse_pubmed_query("cancer NOT 2020:2024[DP]")
|
|
assert "DP" in r.negated_date_ranges
|
|
assert len(r.plain_terms) >= 1
|
|
|
|
def test_a3e_not_date_range_edat(self):
|
|
"""cancer NOT 2020:2024[EDAT] — negated EDAT"""
|
|
r = parse_pubmed_query("cancer NOT 2020:2024[EDAT]")
|
|
assert "EDAT" in r.negated_date_ranges
|
|
|
|
def test_a3e_not_date_range_dep(self):
|
|
"""cancer NOT 2020:2024[DEP] — negated DEP"""
|
|
r = parse_pubmed_query("cancer NOT 2020:2024[DEP]")
|
|
assert "DEP" in r.negated_date_ranges
|
|
|
|
# ── A4: boolean gaps ──
|
|
|
|
def test_a4e_double_paren(self):
|
|
"""((lung OR breast) AND therapy) — nested or flat groups"""
|
|
r = parse_pubmed_query("((lung OR breast) AND therapy)")
|
|
assert len(r.groups) >= 1
|
|
# P11: 括号内的 OR 不影响顶层操作符,顶层全部在括号内 → 无顶层 AND/OR → "and"
|
|
assert r.boolean_operator == "and"
|
|
|
|
def test_a4e_double_paren_operators(self):
|
|
"""((lung OR breast) AND therapy[TI]) — mixed ops"""
|
|
r = parse_pubmed_query("((lung OR breast) AND therapy[TI])")
|
|
assert r.boolean_operator == "and" # P11: 括号内的 OR 不影响顶层
|
|
assert len(r.groups) >= 1
|
|
assert r.has_not is False
|
|
|
|
def test_a4f_precedence_or_and(self):
|
|
"""A OR B AND C — parsed as A OR (B AND C)"""
|
|
r = parse_pubmed_query("A OR B AND C")
|
|
# ParsedPubmedQuery doesn't preserve deep nesting,
|
|
# but boolean_operator should reflect mixed operators
|
|
assert r.boolean_operator == "mixed"
|
|
|
|
# ── A5: mixed queries (plain + tagged) ──
|
|
|
|
def test_a5a_mixed_plain_field(self):
|
|
"""cancer drug[TI] — plain + tagged"""
|
|
r = parse_pubmed_query("cancer drug[TI]")
|
|
assert len(r.plain_terms) >= 1
|
|
cancer_plain = [t for t in r.plain_terms if t.text == "cancer"]
|
|
assert len(cancer_plain) >= 1
|
|
assert len(r.title_terms) == 1
|
|
assert r.title_terms[0].text == "drug"
|
|
|
|
def test_a5b_mixed_and_tagged(self):
|
|
"""cancer AND lung[TI] — boolean with mixed"""
|
|
r = parse_pubmed_query("cancer AND lung[TI]")
|
|
assert len(r.plain_terms) >= 1
|
|
assert r.title_terms[0].text == "lung"
|
|
assert r.boolean_operator == "and"
|
|
|
|
def test_a5c_quoted_plain_with_tag(self):
|
|
""""breast cancer" therapy[TI] — quoted plain + tagged"""
|
|
r = parse_pubmed_query('"breast cancer" therapy[TI]')
|
|
assert len(r.plain_terms) == 1
|
|
assert r.plain_terms[0].text == "breast cancer"
|
|
assert r.plain_terms[0].exact is True
|
|
assert len(r.title_terms) == 1
|
|
assert r.title_terms[0].text == "therapy"
|
|
|
|
# ── A6: edge cases ──
|
|
|
|
def test_a6c_over_100_tokens(self):
|
|
"""More than 100 tokens — tokeniser raises ParseError, parser degrades to plain terms"""
|
|
words = "word " * 101
|
|
r = parse_pubmed_query(words.strip())
|
|
assert isinstance(r, ParsedPubmedQuery)
|
|
# Degradation returns all 101 words as plain_terms (P0-1)
|
|
assert len(r.plain_terms) == 101
|
|
|
|
def test_a6_repeated_AND(self):
|
|
"""AND AND — repeated boolean"""
|
|
r = parse_pubmed_query("cancer AND AND lung")
|
|
assert isinstance(r, ParsedPubmedQuery)
|
|
|
|
def test_a6_trailing_field_tag(self):
|
|
"""cancer[TI]] — double bracket"""
|
|
r = parse_pubmed_query("cancer[TI]]")
|
|
assert isinstance(r, ParsedPubmedQuery)
|
|
|
|
def test_a6_missing_close_bracket(self):
|
|
"""cancer[TI without close"""
|
|
r = parse_pubmed_query("cancer[TI")
|
|
assert isinstance(r, ParsedPubmedQuery)
|
|
|
|
def test_a6_unknown_field(self):
|
|
"""cancer[XX] — unknown field tag degrades to plain text (P0-1)"""
|
|
r = parse_pubmed_query("cancer[XX]")
|
|
assert isinstance(r, ParsedPubmedQuery)
|
|
# P0-1: unknown field tag now emits WORD token instead of aborting
|
|
assert len(r.plain_terms) >= 1
|
|
|
|
|
|
class TestFieldTagCompleteness:
|
|
"""Verify all SPECIAL_FIELDS and FIELD_TAG_MAP values work."""
|
|
|
|
def test_special_fields_are_valid(self):
|
|
"""All _SPECIAL_FIELDS values must be parseable."""
|
|
for tag in sorted(_SPECIAL_FIELDS):
|
|
r = parse_pubmed_query(f'"test"[{tag}]')
|
|
assert isinstance(r, ParsedPubmedQuery), f"Failed for SPECIAL_FIELD [{tag}]"
|
|
|
|
def test_field_tag_map_values_parseable(self):
|
|
"""All _FIELD_TAG_MAP values must be parseable as field tags."""
|
|
for pubmed_tag, internal_field in _FIELD_TAG_MAP.items():
|
|
r = parse_pubmed_query(f'"test"[{pubmed_tag}]')
|
|
assert isinstance(r, ParsedPubmedQuery), f"Failed for map tag [{pubmed_tag}]→{internal_field}"
|
|
|
|
|
|
class TestNegatedTerms:
|
|
"""NOT on specific fields (not just plain)."""
|
|
|
|
def test_not_title(self):
|
|
""""lung"[TI] NOT "cancer"[TI]"""
|
|
r = parse_pubmed_query('"lung"[TI] NOT "cancer"[TI]')
|
|
assert len(r.title_terms) == 2
|
|
lung = [t for t in r.title_terms if t.text == "lung"]
|
|
cancer = [t for t in r.title_terms if t.text == "cancer"]
|
|
assert lung[0].is_not is False
|
|
assert cancer[0].is_not is True
|
|
assert r.has_not is True
|
|
|
|
def test_not_mesh(self):
|
|
""""lung"[TI] NOT "breast neoplasms"[MH]"""
|
|
r = parse_pubmed_query('"lung"[TI] NOT "breast neoplasms"[MH]')
|
|
assert r.has_not is True
|
|
assert len(r.mesh_terms) == 1
|
|
assert r.mesh_terms[0].is_not is True
|
|
|
|
def test_not_uid(self):
|
|
""""12345"[TI] NOT 67890[UID]"""
|
|
r = parse_pubmed_query('"12345"[TI] NOT 67890[UID]')
|
|
assert r.has_not is True
|
|
assert len(r.uid_terms) == 1
|
|
assert r.uid_terms[0].is_not is True
|
|
|
|
def test_not_author(self):
|
|
""""Smith"[AU] NOT "Jones"[AU]"""
|
|
r = parse_pubmed_query('"Smith"[AU] NOT "Jones"[AU]')
|
|
assert len(r.author_terms) == 2
|
|
jones = [t for t in r.author_terms if t.text == "Jones"]
|
|
assert jones[0].is_not is True
|
|
assert r.has_not is True
|
|
|
|
def test_not_not_cancels(self):
|
|
"""NOT NOT cancer — double negation cancels out"""
|
|
r = parse_pubmed_query("NOT NOT cancer")
|
|
# cancer should be positive (NOT NOT X = X)
|
|
plain = [t for t in r.plain_terms if t.text == "cancer"]
|
|
assert len(plain) == 1
|
|
assert plain[0].is_not is False
|
|
# no literal "NOT" as search term
|
|
not_terms = [t for t in r.plain_terms if t.text.upper() == "NOT"]
|
|
assert len(not_terms) == 0, f"Second NOT should not be a literal term: {r.plain_terms}"
|
|
|
|
def test_not_not_date_range(self):
|
|
"""NOT NOT 2024:2025[DP] — double negation on date range"""
|
|
r = parse_pubmed_query("NOT NOT 2024:2025[DP]")
|
|
# After double NOT, the date range should NOT be negated
|
|
assert "DP" not in r.negated_date_ranges, f"negated_date_ranges={r.negated_date_ranges}"
|
|
|
|
def test_mh_noexp_top_level(self):
|
|
"""asthma[MH:noexp] at top level should preserve _noexp flag"""
|
|
r = parse_pubmed_query("asthma[MH:noexp]")
|
|
assert len(r.mesh_terms) == 1
|
|
assert r.mesh_terms[0]._noexp is True
|
|
assert r.mesh_terms[0].field == "MH"
|
|
|
|
def test_not_group_and(self):
|
|
"""NOT (cancer AND tumor) — group NOT should set group_negated"""
|
|
r = parse_pubmed_query("NOT (cancer AND tumor)")
|
|
assert len(r.groups) >= 1
|
|
assert len(r.group_negated) >= 1
|
|
assert r.group_negated[0] is True
|
|
for group in r.groups:
|
|
for term in group:
|
|
assert term.is_not is False, f"NOT group terms should have is_not=False (group_negated tracks negation): {group}"
|
|
|
|
def test_not_group_or(self):
|
|
"""NOT (cancer OR tumor) — group NOT with OR"""
|
|
r = parse_pubmed_query("NOT (cancer OR tumor)")
|
|
assert len(r.groups) >= 1
|
|
assert len(r.group_negated) >= 1
|
|
assert r.group_negated[0] is True
|
|
for group in r.groups:
|
|
for term in group:
|
|
assert term.is_not is False
|
|
|
|
def test_triple_not(self):
|
|
"""NOT NOT NOT cancer = NOT cancer"""
|
|
r = parse_pubmed_query("NOT NOT NOT cancer")
|
|
plain = [t for t in r.plain_terms if t.text == "cancer"]
|
|
assert len(plain) == 1
|
|
assert plain[0].is_not is True # odd count = negated
|