pdac-genomics-agent-dev / tests /test_modality_surface.py
avoigt1121
fix(ui): offer the SV modality; tests own the unsupported-modality refusal
9f3852c
Raw
History Blame Contribute Delete
7.97 kB
"""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"])