"""The modality surface: the UI radio, the machine endpoint, and the refusal between them. Two facts drive this file, and they pull in opposite directions. **A closed-choice widget can never exercise the refusal.** `bs_modality` is a `gr.Radio`, so gradio validates the submitted value against the radio's own `choices` and returns a generic ``Value: 'fusion' … is not in the list of choices`` before `variant_by_subtype` is called. That is true for ANY choice set — widening the radio to three options does not make the fourth value reachable, it just moves the wall. So the `unsupported_modality` refusal cannot be owned by a click-through, ever, and these tests own it instead. That is not a stopgap; it is where the case belongs, because the only surface that can carry an unrecognized modality is the `gr.api` machine endpoint the orchestrator calls with a free-form string. **But the radio still has to match the tool.** For the whole life of the SV modality (ADR-0008) the radio's literal was `["mutation", "cnv"]` while `variant_by_subtype` accepted `sv` and `deploy/orchestrator_registration.yaml` published it — the UI offering a narrower contract than the machine endpoint, which is the same family of failure as the modality fallback this refusal was written to prevent: the surface answering a different question than the one on the label. `test_ui_modality_choices_equal_the_tools_closed_set` is the regression that would have caught it on the day `sv` landed, and will catch the fourth modality too. """ from __future__ import annotations import json import pytest import gradio_ui from src.tools.variant_by_subtype import SUPPORTED_MODALITIES, variant_by_subtype class _Request: """Minimal stand-in for `gr.Request` — only `.headers.get` is used.""" def __init__(self, headers=None): self.headers = headers or {} @pytest.fixture(autouse=True) def _open_gate(monkeypatch, tmp_path): """These tests are about the modality contract, not the auth gate (test_access owns that).""" monkeypatch.delenv("ACCESS_CONTROL", raising=False) monkeypatch.setenv("LOG_SINK", "local") monkeypatch.setenv("LOG_SINK_LOCAL_DIR", str(tmp_path / "run_logs")) def _modality_radio(): """The Variant × subtype tab's modality radio, found in the built Blocks graph.""" radios = [ block for block in gradio_ui.demo.blocks.values() if block.__class__.__name__ == "Radio" and getattr(block, "label", None) == "Modality" ] assert len(radios) == 1, f"expected exactly one Modality radio, found {len(radios)}" return radios[0] # --- the UI surface must equal the tool's closed set ---------------------------------- def test_ui_modality_choices_equal_the_tools_closed_set(): """The radio is DERIVED from `SUPPORTED_MODALITIES`; this locks that it stays derived. Fails if a modality is added to the tool and not the UI (the 2026-08-05 `sv` gap), and fails if a value is added to the radio that the tool would refuse — which would turn an ordinary user click into an `unsupported_modality` error. """ # gradio normalizes `choices` to (label, value) pairs. offered = {c[1] if isinstance(c, (tuple, list)) else c for c in _modality_radio().choices} assert offered == set(SUPPORTED_MODALITIES) def test_sv_is_offered_and_mutation_stays_the_default(): radio = _modality_radio() offered = {c[1] if isinstance(c, (tuple, list)) else c for c in radio.choices} assert "sv" in offered, "ADR-0008's SV modality must be reachable from the UI" assert radio.value == "mutation" # --- the refusal, on the only surface that can reach it ------------------------------- @pytest.mark.parametrize("bad", ["fusion", "mutaton", "expression", "SV_STATUS"]) def test_machine_endpoint_refuses_an_unsupported_modality(patched_cbio, bad): """The `gr.api` endpoint is where a free-form modality string actually arrives. The radio cannot produce one; the orchestrator can, and did — this is the path that used to answer a fusion question with a mutation association and `join_available: true`. """ body = json.loads( gradio_ui.variant_by_subtype( genes="KRAS", source="cbioportal:paad_tcga", modality=bad, request=_Request() ) ) assert body["join_available"] is False assert body["route"] == "unsupported_modality" assert set(body["supported_modalities"]) == set(SUPPORTED_MODALITIES) assert "genes" not in body, "no association may be computed for a refused modality" def test_machine_endpoint_keeps_blank_meaning_unspecified(patched_cbio): """Blank is the ordinary case, NOT an unknown value — an untouched field sends "". Guarded here as well as at the tool, because the normalization that makes it work (`(modality or "mutation").strip()`) lives in `gradio_ui`, and a refusal here would break every orchestrator call that leaves the field alone. """ for blank in ("", " ", "\t"): body = json.loads( gradio_ui.variant_by_subtype( genes="KRAS", source="cbioportal:paad_tcga", modality=blank, request=_Request() ) ) assert body.get("route") != "unsupported_modality", f"blank {blank!r} means 'unspecified'" assert body["modality"] == "mutation" # --- a refusal must be VISIBLE, not just present in the JSON -------------------------- # # Both routes below became clickable when `sv` joined the radio: 5 of the 7 curated cohorts # publish no SV at all. Before this, neither had a caution branch — they fell through to # `caveats`, which a refusal does not carry, so the box stayed `visible=False` and the reader # saw an empty plot and nothing else. That is the ADR-0004 invisible-denial failure in a second # place: an answer that refuses in the JSON and looks merely empty on screen. def test_unavailable_modality_is_visible_in_the_caution_box(patched_cbio): result = variant_by_subtype(["KRAS"], "cbioportal:paad_tcga", modality="sv") assert result["route"] == "unavailable_modality" # TCGA publishes no SV caution = gradio_ui._subtype_caution_md(result) assert caution["visible"] is True assert "no sv data" in caution["value"].lower() def test_unsupported_modality_is_visible_in_the_caution_box(patched_cbio): result = variant_by_subtype(["KRAS"], "cbioportal:paad_tcga", modality="fusion") assert result["route"] == "unsupported_modality" caution = gradio_ui._subtype_caution_md(result) assert caution["visible"] is True assert "unsupported modality" in caution["value"].lower() for modality in SUPPORTED_MODALITIES: assert modality in caution["value"] # --- the SV join is a real capability, not a decorative choice ------------------------- def test_sv_join_returns_counts_and_the_sparsity_caveat(): # No `patched_cbio`: this reads the COMMITTED `ccle_broad_2019` artifact, the only curated # cohort carrying both SV and a subtype-shaped clinical attribute. Still hermetic — since # ADR-0005 C4 the request path reads artifacts and never calls the API. """Adding the choice is only defensible if `sv` can actually answer. It can: on a cohort carrying both SV and a named subtype attribute, the join runs and returns contingency counts. Most genes fall below `MIN_ALTERED_FOR_TEST` and report `testable: false` — that is the honest form of a rare event, and the SV caveat says so, so a reader cannot mistake sparse counts for an absence of association. """ result = variant_by_subtype( ["KRAS", "NRG1"], "cbioportal:ccle_broad_2019", subtype_attribute="SUBTYPE", modality="sv", ) assert result["join_available"] is True assert result["modality"] == "sv" assert result["genes"], "an SV join must return per-gene blocks, not an empty answer" assert any("Structural variants are RARE" in c for c in result["caveats"])