Files
backend/backend/tests/test_comprehensive_verify.py
T
34047007@qq.com 2b6ffd6de4
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
fix: 第19轮搜索审计修复 — NOT组语义/公共搜索/AND聚类等10项
- 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完全化
2026-07-28 16:11:19 +08:00

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