| from __future__ import annotations |
|
|
| import importlib |
| import sys |
| from pathlib import Path |
| from types import SimpleNamespace |
| from typing import Any |
|
|
| from gcmd_classifier.models import ( |
| ArticleClassificationOutcome, |
| ArticleProcessingStatus, |
| ArticleRecord, |
| ArticleResult, |
| ClassificationFinalStatus, |
| ClassificationRecord, |
| DeterministicValidationResult, |
| OutputError, |
| OutputWarning, |
| ProcessingMetadata, |
| ReviewStatus, |
| SupportType, |
| ) |
| from gcmd_classifier.ui import gradio_app |
| from gcmd_classifier.vocabulary import load_vocabulary |
|
|
| FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json") |
| PROTOTYPE_PATH = Path("prototype/app_hf_poc.py") |
|
|
|
|
| class FakeComponent: |
| instances: list[FakeComponent] = [] |
|
|
| def __init__(self, *args: Any, **kwargs: Any) -> None: |
| self.args = args |
| self.kwargs = kwargs |
| self.click_kwargs: dict[str, Any] | None = None |
| type(self).instances.append(self) |
|
|
| def click(self, **kwargs: Any) -> None: |
| self.click_kwargs = kwargs |
|
|
|
|
| class FakeLayout: |
| def __init__(self, **kwargs: Any) -> None: |
| self.kwargs = kwargs |
|
|
| def __enter__(self): |
| return self |
|
|
| def __exit__(self, exc_type, exc, traceback) -> None: |
| return None |
|
|
|
|
| class FakeBlocks(FakeLayout): |
| launched = 0 |
| queued = 0 |
|
|
| def queue(self): |
| type(self).queued += 1 |
| return self |
|
|
| def launch(self, **kwargs: Any) -> None: |
| self.launch_kwargs = kwargs |
| type(self).launched += 1 |
|
|
|
|
| def _fake_gradio_module() -> SimpleNamespace: |
| FakeComponent.instances = [] |
| return SimpleNamespace( |
| Blocks=FakeBlocks, |
| Group=FakeLayout, |
| Row=FakeLayout, |
| Textbox=FakeComponent, |
| Number=FakeComponent, |
| Markdown=FakeComponent, |
| Dataframe=FakeComponent, |
| Button=FakeComponent, |
| JSON=FakeComponent, |
| ) |
|
|
|
|
| def _no_classification_result(article: ArticleRecord) -> ArticleResult: |
| return ArticleResult( |
| DOI=article.DOI, |
| Title=article.Title, |
| Year=article.Year, |
| Abstract=article.Abstract, |
| processing_status=ArticleProcessingStatus.COMPLETED, |
| classification_outcome=ArticleClassificationOutcome.NOT_CLASSIFIED, |
| classifications=(), |
| no_classification_reason="No Topic selected.", |
| review_status=ReviewStatus.NOT_REQUIRED, |
| processing_metadata=ProcessingMetadata(cache_used=False), |
| ) |
|
|
|
|
| def _classification_record() -> ClassificationRecord: |
| return ClassificationRecord( |
| UUID="vl2-carbon-dioxide", |
| name="CARBON DIOXIDE", |
| level="Variable_Level_2", |
| canonical_path="ATMOSPHERE > ATMOSPHERIC CHEMISTRY > CARBON > CARBON DIOXIDE", |
| path_components=( |
| "ATMOSPHERE", |
| "ATMOSPHERIC CHEMISTRY", |
| "CARBON", |
| "CARBON DIOXIDE", |
| ), |
| topic="ATMOSPHERE", |
| term="ATMOSPHERIC CHEMISTRY", |
| parent_uuid="vl1-atmosphere-carbon", |
| branch_id="topic:t/term:x/variable:a", |
| classifier_evidence="The article discusses atmospheric carbon dioxide.", |
| support_type=SupportType.EXPLICIT, |
| reason_for_stopping="Deepest supported concept.", |
| deterministic_validation=DeterministicValidationResult(valid=True), |
| final_status=ClassificationFinalStatus.ACCEPTED, |
| review_required=False, |
| review_status=ReviewStatus.NOT_REQUIRED, |
| ) |
|
|
|
|
| def _classified_result(article: ArticleRecord) -> ArticleResult: |
| return ArticleResult( |
| DOI=article.DOI, |
| Title=article.Title, |
| Year=article.Year, |
| Abstract=article.Abstract, |
| processing_status=ArticleProcessingStatus.COMPLETED, |
| classification_outcome=ArticleClassificationOutcome.CLASSIFIED, |
| classifications=(_classification_record(),), |
| review_status=ReviewStatus.NOT_REQUIRED, |
| warnings=(OutputWarning(code="TEST_WARNING", message="A warning.", stage="test"),), |
| errors=(OutputError(code="TEST_ERROR", message="An error.", stage="test"),), |
| processing_metadata=ProcessingMetadata(cache_used=False), |
| ) |
|
|
|
|
| def test_gradio_app_imports_without_gradio_or_api_keys() -> None: |
| module = importlib.import_module("gcmd_classifier.ui.gradio_app") |
|
|
| assert hasattr(module, "create_demo") |
|
|
|
|
| def test_create_demo_constructs_gradio_interface(monkeypatch) -> None: |
| monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module()) |
|
|
| demo = gradio_app.create_demo(vocabulary=load_vocabulary(FIXTURE_PATH)) |
|
|
| assert isinstance(demo, FakeBlocks) |
| assert "results-table" in gradio_app.GRADIO_CSS |
| dataframes = [ |
| component |
| for component in FakeComponent.instances |
| if component.kwargs.get("headers") == gradio_app.CLASSIFICATION_TABLE_COLUMNS |
| ] |
| assert dataframes |
| assert dataframes[0].kwargs["wrap"] is True |
| assert dataframes[0].kwargs["elem_id"] == "classification-results-table" |
| assert "overflow-wrap: anywhere" in gradio_app.GRADIO_CSS |
| assert "text-overflow: unset" in gradio_app.GRADIO_CSS |
| assert "overflow: visible" in gradio_app.GRADIO_CSS |
| assert "height: auto" in gradio_app.GRADIO_CSS |
|
|
|
|
| def test_ui_module_does_not_classify_or_call_model_at_import_time(monkeypatch) -> None: |
| calls = {"classified": 0} |
|
|
| def fake_classify(**kwargs): |
| calls["classified"] += 1 |
| return _no_classification_result(kwargs["article"]) |
|
|
| monkeypatch.setattr(gradio_app.pipeline_service, "classify_article", fake_classify) |
| importlib.reload(gradio_app) |
|
|
| assert calls["classified"] == 0 |
|
|
|
|
| def test_root_app_imports_without_launching_server(monkeypatch) -> None: |
| FakeBlocks.launched = 0 |
| FakeBlocks.queued = 0 |
| monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module()) |
| sys.modules.pop("app", None) |
|
|
| module = importlib.import_module("app") |
|
|
| assert isinstance(module.demo, FakeBlocks) |
| assert FakeBlocks.launched == 0 |
| assert FakeBlocks.queued == 0 |
|
|
|
|
| def test_root_app_imports_when_spaces_is_not_installed(monkeypatch) -> None: |
| monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module()) |
| monkeypatch.delitem(sys.modules, "spaces", raising=False) |
| sys.modules.pop("app", None) |
|
|
| module = importlib.import_module("app") |
|
|
| assert module._zerogpu_startup_probe() == "ok" |
|
|
|
|
| def test_root_app_defines_zerogpu_probe_with_spaces_decorator(monkeypatch) -> None: |
| decorated = [] |
|
|
| class FakeSpaces: |
| @staticmethod |
| def GPU(function): |
| decorated.append(function.__name__) |
| function._fake_gpu_decorated = True |
| return function |
|
|
| monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module()) |
| monkeypatch.setitem(sys.modules, "spaces", FakeSpaces) |
| sys.modules.pop("app", None) |
|
|
| module = importlib.import_module("app") |
|
|
| assert decorated == ["_zerogpu_startup_probe"] |
| assert module._zerogpu_startup_probe._fake_gpu_decorated is True |
|
|
|
|
| def test_root_app_launch_uses_spaces_compatible_settings(monkeypatch) -> None: |
| FakeBlocks.launched = 0 |
| FakeBlocks.queued = 0 |
| monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module()) |
| monkeypatch.setenv("GRADIO_SERVER_NAME", "127.0.0.1") |
| monkeypatch.setenv("GRADIO_SERVER_PORT", "9999") |
| sys.modules.pop("app", None) |
| module = importlib.import_module("app") |
|
|
| module.launch() |
|
|
| assert FakeBlocks.queued == 1 |
| assert FakeBlocks.launched == 1 |
| assert module.demo.launch_kwargs == { |
| "css": module.GRADIO_CSS, |
| "server_name": "127.0.0.1", |
| "server_port": 9999, |
| "share": False, |
| "prevent_thread_lock": False, |
| } |
|
|
|
|
| def test_root_app_uses_spaces_default_server_settings(monkeypatch) -> None: |
| monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module()) |
| monkeypatch.delenv("GRADIO_SERVER_NAME", raising=False) |
| monkeypatch.delenv("GRADIO_SERVER_PORT", raising=False) |
| sys.modules.pop("app", None) |
| module = importlib.import_module("app") |
|
|
| assert module.gradio_server_name() == "0.0.0.0" |
| assert module.gradio_server_port() == 7860 |
|
|
|
|
| def test_root_app_is_thin_launcher_without_classification_logic() -> None: |
| text = Path("app.py").read_text() |
|
|
| assert "from gcmd_classifier.ui.gradio_app import GRADIO_CSS, create_demo" in text |
| assert "classify_article" not in text |
| assert "route_topics" not in text |
| assert "OpenAI" not in text |
| assert "@spaces.GPU" in text |
| assert "def _zerogpu_startup_probe" in text |
| assert "demo.queue().launch" in text |
| assert "server_name=gradio_server_name()" in text |
| assert "server_port=gradio_server_port()" in text |
| assert "share=False" in text |
| assert "prevent_thread_lock=False" in text |
|
|
|
|
| def test_ui_calls_pipeline_service(monkeypatch) -> None: |
| calls = {"count": 0} |
|
|
| def fake_classify(**kwargs): |
| calls["count"] += 1 |
| return _no_classification_result(kwargs["article"]) |
|
|
| monkeypatch.setattr(gradio_app.pipeline_service, "classify_article", fake_classify) |
|
|
| summary, table, payload, diagnostics = gradio_app.run_demo_classification( |
| Title="A title", |
| Abstract="", |
| DOI="10.example/ui", |
| Year=2025, |
| vocabulary=load_vocabulary(FIXTURE_PATH), |
| model_client_factory=lambda settings: object(), |
| ) |
|
|
| assert calls["count"] == 1 |
| assert "not_classified" in summary |
| assert table == [] |
| assert payload["DOI"] == "10.example/ui" |
| assert diagnostics["errors"] == [] |
|
|
|
|
| def test_no_classification_result_is_formatted_correctly() -> None: |
| article = ArticleRecord(DOI="10.example/no", Title="No", Year=2025, Abstract="") |
| summary = gradio_app.format_classification_summary(_no_classification_result(article)) |
|
|
| assert "not_classified" in summary |
| assert "No Topic selected." in summary |
|
|
|
|
| def test_classified_result_includes_uuid_and_canonical_path() -> None: |
| article = ArticleRecord(DOI="10.example/yes", Title="Yes", Year=2025, Abstract="Text.") |
| summary = gradio_app.format_classification_summary(_classified_result(article)) |
|
|
| assert "vl2-carbon-dioxide" in summary |
| assert "ATMOSPHERE > ATMOSPHERIC CHEMISTRY > CARBON > CARBON DIOXIDE" in summary |
| assert "Variable_Level_2" in summary |
| assert "The article discusses atmospheric carbon dioxide." in summary |
|
|
|
|
| def test_compact_summary_includes_status_model_and_review_counts() -> None: |
| article = ArticleRecord(DOI="10.example/summary", Title="Summary", Year=2025, Abstract="Text.") |
| result = _classified_result(article).model_copy( |
| update={ |
| "processing_metadata": ProcessingMetadata( |
| model_provider="fake", |
| model_name="fake-model", |
| ), |
| "classifications": ( |
| _classification_record().model_copy(update={"review_required": True}), |
| ), |
| } |
| ) |
|
|
| summary = gradio_app.format_compact_summary(result) |
|
|
| assert "processing_status" in summary |
| assert "classification_outcome" in summary |
| assert "model_provider:** `fake`" in summary |
| assert "model_name:** `fake-model`" in summary |
| assert "classifications:** `1`" in summary |
| assert "requiring_review:** `1`" in summary |
|
|
|
|
| def test_classification_table_rows_include_only_demo_relevant_fields() -> None: |
| article = ArticleRecord(DOI="10.example/table", Title="Table", Year=2025, Abstract="Text.") |
| result = _classified_result(article) |
|
|
| rows = gradio_app.classification_table_rows(result) |
|
|
| assert gradio_app.CLASSIFICATION_TABLE_COLUMNS == [ |
| "GCMD Keyword Path", |
| "Evidence", |
| "Support", |
| "Review Required", |
| ] |
| assert rows == [ |
| [ |
| "ATMOSPHERE > ATMOSPHERIC CHEMISTRY > CARBON > CARBON DIOXIDE", |
| "The article discusses atmospheric carbon dioxide.", |
| "explicit", |
| False, |
| ] |
| ] |
|
|
|
|
| def test_errors_and_warnings_are_displayed() -> None: |
| article = ArticleRecord( |
| DOI="10.example/diagnostics", |
| Title="Diagnostics", |
| Year=2025, |
| Abstract="", |
| ) |
| result = _classified_result(article) |
|
|
| summary = gradio_app.format_classification_summary(result) |
| diagnostics = gradio_app.diagnostics_payload(result) |
|
|
| assert "TEST_WARNING" in summary |
| assert "TEST_ERROR" in summary |
| assert diagnostics["warnings"][0]["code"] == "TEST_WARNING" |
| assert diagnostics["errors"][0]["code"] == "TEST_ERROR" |
|
|
|
|
| def test_empty_abstract_is_accepted_by_ui_input_path(monkeypatch) -> None: |
| seen = {"abstract": None} |
|
|
| def fake_classify(**kwargs): |
| article = kwargs["article"] |
| seen["abstract"] = article.Abstract |
| return _no_classification_result(article) |
|
|
| monkeypatch.setattr(gradio_app.pipeline_service, "classify_article", fake_classify) |
|
|
| summary, table, payload, _ = gradio_app.run_demo_classification( |
| Title="Title only", |
| Abstract="", |
| DOI="10.example/title-only", |
| Year=None, |
| vocabulary=load_vocabulary(FIXTURE_PATH), |
| model_client_factory=lambda settings: object(), |
| ) |
|
|
| assert seen["abstract"] == "" |
| assert table == [] |
| assert payload["Abstract"] == "" |
| assert "not_classified" in summary |
|
|
|
|
| def test_prototype_app_remains_unchanged_during_ui_import(monkeypatch) -> None: |
| before = PROTOTYPE_PATH.read_bytes() |
| monkeypatch.setitem(sys.modules, "gradio", _fake_gradio_module()) |
| gradio_app.create_demo(vocabulary=load_vocabulary(FIXTURE_PATH)) |
| after = PROTOTYPE_PATH.read_bytes() |
|
|
| assert after == before |
|
|