"""Nearest-subject matching for journal browse (v1.8.3). A non-exact but meaningful subject such as "physics" must return journals for the nearest verified SCImago term and say so, whether or not the LLM classifier is available. Meaningless input must still return no journals. """ from __future__ import annotations import pytest from fastapi.testclient import TestClient import app from scimago_index import ( _SUBJECT_ALIASES, nearest_subject_term, subject_vocabulary, ) def test_every_alias_target_and_alternative_is_real(): vocab = subject_vocabulary() for key, alias in _SUBJECT_ALIASES.items(): pool = vocab["areas"] if alias["kind"] == "area" else vocab["categories"] assert alias["name"] in pool, (key, alias["name"]) for alt in alias["alternatives"]: assert alt in vocab["categories"], (key, alt) @pytest.mark.parametrize("text, kind, name", [ ("physics", "area", "physics and astronomy"), ("Physics", "area", "physics and astronomy"), ("maths", "area", "mathematics"), ("biology", "area", "biochemistry, genetics and molecular biology"), ("economics", "area", "economics, econometrics and finance"), ("machine learning", "category", "artificial intelligence"), ("astronomy", "category", "astronomy and astrophysics"), ("chemical", "area", "chemical engineering"), ("humanities", "area", "arts and humanities"), ("electrical engineering", "category", "electrical and electronic engineering"), ("petroleum engineering", "category", "energy engineering and power technology"), ("criminology", "category", "sociology and political science"), ("film", "category", "visual arts and performing arts"), ("animal", "category", "animal science and zoology"), ("ocean", "category", "oceanography"), ("business", "area", "business, management and accounting"), ]) def test_nearest_subject_term(text, kind, name): match = nearest_subject_term(text) assert match is not None assert (match["kind"], match["name"]) == (kind, name) vocab = subject_vocabulary()["categories"] assert all(alt in vocab for alt in match["alternatives"]) @pytest.mark.parametrize("text", ["science", "learning", "xyzzy", "asdf qwer", "quantum computing", "planning", "behavior", "family", "design"]) def test_generic_or_meaningless_input_is_not_forced(text): assert nearest_subject_term(text) is None def test_partial_match_only_when_requested(): assert nearest_subject_term("quantum physics") is not None # alias assert nearest_subject_term("geology rocks") is None partial = nearest_subject_term("geology rocks", allow_partial=True) assert partial["name"] == "geology" and partial["method"] == "partial_token_match" # ── Endpoint ───────────────────────────────────────────────────────────── def _client(monkeypatch, llm): calls = [] async def fake_openai(_client, system, user, max_tokens=0, model=None): calls.append(user) if llm == "down": raise app.HTTPException(503, "AI service is temporarily unavailable.") if llm == "not_found": return {"confidence": "not_found", "normalised": "", "scimago_category": "", "scimago_area": "", "nearest_categories": [], "mapping_type": "none", "use_nearest_category": False, "user_message": "Could not classify."} if llm == "maps": return {"confidence": "near", "normalised": "Robotics-like", "scimago_category": "Control and Systems Engineering", "scimago_area": "", "nearest_categories": ["Control and Systems Engineering"], "mapping_type": "nearest_category", "use_nearest_category": True, "user_message": ""} return {"selected": [], "global_notes": ""} async def no_evidence(*_a, **_k): return None monkeypatch.setattr(app, "call_openai", fake_openai) monkeypatch.setattr(app, "get_client", lambda: None) monkeypatch.setattr(app, "check_rate_limit", lambda *a: True) monkeypatch.setattr(app, "_attach_article_evidence", no_evidence) return TestClient(app.app), calls def _browse(client, subject): response = client.post("/api/browse-subject", json={"subject": subject}) assert response.status_code == 200 return response.json()["result"] @pytest.mark.parametrize("llm", ["down", "not_found"]) def test_physics_returns_nearest_area_with_disclosure(monkeypatch, llm): client, calls = _client(monkeypatch, llm) result = _browse(client, "physics") assert calls == [] # resolved deterministically; no model cost assert result["total"] > 0 and result["journals"] assert result["confidence"] == "near" mapping = result["subject_mapping"] assert mapping["selected_area"] == "physics and astronomy" assert mapping["used_nearest_category"] is True assert 'no exact subject category for "physics"' in result["user_message"] assert "nearest verified subject area" in result["user_message"] assert mapping["alternatives"] def test_synonym_is_disclosed_as_interpretation(monkeypatch): client, _ = _client(monkeypatch, "down") result = _browse(client, "maths") assert result["total"] > 0 assert result["subject_mapping"]["match_type"] == "synonym" assert result["user_message"].startswith('Interpreted "maths"') def test_llm_mapping_still_used_for_unknown_terms(monkeypatch): client, calls = _client(monkeypatch, "maps") result = _browse(client, "swarm robotics for warehouses") assert len(calls) == 1 assert result["subject_mapping"]["selected_category"].lower() == "control and systems engineering" assert result["total"] > 0 def test_partial_fallback_after_llm_failure(monkeypatch): client, calls = _client(monkeypatch, "down") result = _browse(client, "geology rocks") assert len(calls) == 1 assert result["total"] > 0 assert result["subject_mapping"]["selected_category"] == "geology" assert "nearest verified category" in result["user_message"] assert "classifier was unavailable" not in result["user_message"] @pytest.mark.parametrize("llm", ["down", "not_found"]) def test_meaningless_input_still_returns_no_journals(monkeypatch, llm): client, _ = _client(monkeypatch, llm) result = _browse(client, "xyzzy") assert result["confidence"] == "not_found" assert result["journals"] == [] and result["total"] == 0 def test_exact_and_spelling_paths_unchanged(monkeypatch): client, calls = _client(monkeypatch, "down") exact = _browse(client, "Condensed Matter Physics") assert exact["confidence"] == "exact" and exact["total"] > 0 spelled = _browse(client, "compter scince") assert spelled["subject_mapping"]["spelling_corrected"] is True # The correction notice appears once, not twice. assert spelled["user_message"].count("Spelling corrected") == 1 assert calls == []