File size: 11,147 Bytes
82c2353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819da30
 
 
 
 
82c2353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408908b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82c2353
 
408908b
 
82c2353
408908b
 
82c2353
408908b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82c2353
 
408908b
 
82c2353
408908b
 
82c2353
 
 
 
 
 
408908b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82c2353
 
 
 
 
 
 
 
 
 
 
 
 
 
819da30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408908b
 
2f6f12d
408908b
 
 
2f6f12d
 
 
 
 
 
 
 
 
 
 
 
 
408908b
819da30
 
82c2353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
"""What the SCREEN says, not what the payload says — the two ways it disagreed.

Both bugs guarded here were found by clicking through the deployed Space on 2026-08-05, and
neither was visible to any existing test: every payload involved was correct. The tests below
therefore assert on the rendered surface (the plot's colour scale, the caution markdown), which
is the only place these failed.

1. The chart derived its colour scale from whatever modalities the CURRENT frame held, and the
   frontend did not rebuild that domain when the next frame held more. A single-gene query
   followed by a full-panel query drew an empty sv-only chart while the JSON reported KRAS at
   93.7%. Pinning `color_map` makes the domain independent of the data.
2. `caveats` — which carries the ADR-0008 SV annotation-depth warning — was rendered only on the
   subtype tab, never on the variant-status tab where SV data is actually queried.
"""

from __future__ import annotations

import gradio_ui


def _result(genes, *, n_profiled=None, caveats=None, **extra):
    payload = {
        "source": "cbioportal:test",
        "grounded": True,
        "genes": genes,
        "n_profiled": n_profiled or {"mutation": 100, "cnv": 100, "sv": 100},
    }
    if caveats is not None:
        payload["caveats"] = caveats
    payload.update(extra)
    return payload


def _freq(pct):
    return {"frequency": pct / 100, "n_altered": int(pct), "n_profiled": 100}


class _Profile:
    def __init__(self, username):
        self.username = username


def _rows(plot):
    """`plot.value` is the serialised {columns, data} the browser receives, not a DataFrame."""
    cols = plot.value["columns"]
    return [dict(zip(cols, row)) for row in plot.value["data"]]


# --------------------------------------------------------------------------- #
# 1. the colour scale must not depend on the frame
# --------------------------------------------------------------------------- #
# The exact live sequence: NRG1 alone on `pdac_msk_2024` yields an SV-ONLY frame, because NRG1
# is off that cohort's DNA panel and carries no mutation/cnv block at all.
_SV_ONLY = _result({"NRG1": {"sv": _freq(0.3)}})
_FULL_PANEL = _result(
    {
        "KRAS": {"mutation": _freq(93.7), "cnv": _freq(1.9), "sv": _freq(0.0)},
        "NRG1": {"sv": _freq(0.3)},
    }
)


def test_one_chart_per_modality_never_a_stacked_one():
    """Stacking summed three modalities into one bar and put KRAS at ~120% on a "% altered" axis.

    Two independent things were wrong with that: a sample can be BOTH mutated and copy-number
    altered, so the parts are not disjoint and adding them double-counts; and the modalities have
    different denominators, so the segments are not percentages of the same thing. A stacked bar
    asserts "these parts make up the whole" and neither held.
    """
    plots = gradio_ui._frequency_plots(_FULL_PANEL)
    assert len(plots) == len(gradio_ui._MODALITY_ORDER) == 3

    for plot in plots:
        # Single series per chart: no `color` encoding at all, so nothing can stack.
        assert getattr(plot, "color", None) is None
        assert set(plot.value["columns"]) == {"gene", "frequency"}


def test_each_chart_names_its_own_denominator():
    """"% altered" is meaningless without saying of what — and it differs per modality.

    `ccle_broad_2019` profiles 53 samples for mutation, 44 for cnv and 41 for sv. Reading one
    axis across three modalities was exactly the mistake the stacked chart invited.
    """
    result = _result(
        {"KRAS": {"mutation": _freq(84.0), "cnv": _freq(60.0), "sv": _freq(2.0)}},
        n_profiled={"mutation": 53, "cnv": 44, "sv": 41},
    )
    titles = [p.title for p in gradio_ui._frequency_plots(result)]
    assert "53 samples profiled" in titles[0]
    assert "44 samples profiled" in titles[1]
    assert "41 samples profiled" in titles[2]


def test_every_axis_starts_at_zero_and_stops_at_100():
    """A bar measured from a non-zero baseline misstates every ratio on the chart.

    Autoscaled, gradio started a CCLE axis at 20%, which made SMAD4 at ~21% look like nothing
    next to KRAS at 90%. And a "% altered" axis has no business running past 100.
    """
    result = _result(
        {"KRAS": {"mutation": _freq(90.6)}, "SMAD4": {"mutation": _freq(22.6)}},
        n_profiled={"mutation": 53},
    )
    low, high = gradio_ui._frequency_plots(result)[0].y_lim
    assert low == 0
    assert 90.6 < high <= 100


def test_a_tiny_but_real_frequency_stays_visible():
    """NRG1 is 6/2336 = 0.26%. On a fixed 0-100 axis that is a sub-pixel sliver.

    Rendering a real finding as nothing is the same failure as dropping it, so the ceiling is
    fitted per chart. The floor stops one tiny bar being stretched to full height instead, which
    would read as "most of the cohort".
    """
    sv_only = _result({"NRG1": {"sv": _freq(0.26)}}, n_profiled={"sv": 2336})
    _, _, sv = gradio_ui._frequency_plots(sv_only)
    assert sv.y_lim[0] == 0
    assert sv.y_lim[1] <= 1.0, "ceiling must fit the data, not the percentage range"
    assert sv.y_lim[1] >= 0.26, "the bar must fit inside the axis"


def test_a_modality_the_answer_lacks_is_hidden_not_drawn_empty():
    """An empty axis reads as "we looked and found none" — the claim we most want not to make.

    `paad_tcga` has no SV profile at all, so its SV chart must not exist on the page rather than
    appear as a flat zero line.
    """
    no_sv = _result(
        {"KRAS": {"mutation": _freq(90.7), "cnv": _freq(2.0)}},
        n_profiled={"mutation": 150, "cnv": 184},
        unavailable_modalities=["sv"],
    )
    mutation, cnv, sv = gradio_ui._frequency_plots(no_sv)
    assert mutation.visible is True and cnv.visible is True
    assert sv == {"__type__": "update", "visible": False}


def test_charts_keep_a_fixed_position_between_answers():
    """Chart 1 is always mutation, chart 3 always SV — a reader should not have to check."""
    wide = gradio_ui._frequency_plots(_FULL_PANEL)
    narrow = gradio_ui._frequency_plots(_SV_ONLY)

    assert "Mutated" in wide[0].title and "fusion" in wide[2].title.lower()
    # The sv-only answer leaves the first two slots empty rather than promoting SV to slot 1.
    assert narrow[0] == {"__type__": "update", "visible": False}
    assert "fusion" in narrow[2].title.lower()
    kras = [r for r in _rows(wide[0]) if r["gene"] == "KRAS"]
    assert kras and kras[0]["frequency"] == 93.7


def test_an_unassayed_gene_is_still_never_given_a_zero():
    """Stabilising the SCALE must not stabilise the DATA by inventing wild-type rows.

    The tempting fix — pad every gene to all three modalities so the category set never changes
    — would report NRG1 as 0% mutated on a cohort that never sequenced it, which is the exact
    confident false negative the `assayed: false` gate exists to prevent.
    """
    frame = gradio_ui._frequency_frame(_FULL_PANEL)
    nrg1 = frame[frame["gene"] == "NRG1"]
    assert set(nrg1["modality"]) == {"sv"}


def test_variant_status_handler_clears_the_plot_before_answering():
    """The chart must be blanked between answers, not overwritten in place.

    Shipped in 23d37b6 without this and caught on prod the same day: a query that NARROWED the
    gene set left the previous answer's bars under the new answer's caution — one cohort's
    numbers beneath another cohort's warning. Both a stale chart and the earlier dropped-marks
    bug are the same failure, just in opposite directions, which is why the test asserts the
    SHAPE of the handler (clear, then answer) rather than either symptom.

    An empty chart claims nothing. A stale chart claims something false.
    """
    import inspect

    assert inspect.isgeneratorfunction(gradio_ui._ui_variant_status)

    frames = list(
        gradio_ui._ui_variant_status(
            "cbioportal:paad_tcga", ["KRAS"], profile=_Profile("anne-voigt")
        )
    )
    assert len(frames) == 2, "expected a clearing frame followed by the answer"
    clearing, answer = frames

    # The clearing frame blanks each result slot's VALUE and leaves visibility alone. Hiding here
    # instead made every query a hide→show flip, and the third chart lost that race about half
    # the time — it stayed unmounted ("null does not exist" in the browser), so a cohort WITH
    # structural variants rendered as mutation+CNV only, reading as "no fusions here".
    #
    # Two spellings of "blanked", because the slots are no longer all charts: a chart clears to a
    # bare `None`, while the summary table and the full-result download clear with an explicit
    # `gr.update(value=None)`. The invariant is the same for all of them and is what this asserts
    # — carry no value, and say nothing about visibility. The download slot matters most here: a
    # stale file link would leave the page entirely and be opened later with nothing to say which
    # query produced it.
    for slot in clearing[1:-1]:
        if slot is None:
            continue
        assert isinstance(slot, dict), f"unexpected clearing-frame slot: {slot!r}"
        assert slot.get("value") is None, "clearing frame must carry no value"
        assert "visible" not in slot, "clearing frame must blank, not hide"
    assert any(getattr(u, "value", None) is not None for u in answer[1:-1])


# --------------------------------------------------------------------------- #
# 2. caveats must reach the screen on the tab that produces them
# --------------------------------------------------------------------------- #
_SV_CAVEAT = (
    "This cohort's structural-variant calls carry NO frame annotation - every event is "
    "reported as `rearrangement`, and this cohort can never return `fusion_in_frame`."
)


def test_variant_status_caution_renders_caveats():
    """The ADR-0008 warning has to be visible on the tab where SV data is queried."""
    update = gradio_ui._status_caution_md(
        _result({"KRAS": {"mutation": _freq(90.0)}}, caveats=[_SV_CAVEAT])
    )
    assert update["visible"] is True
    assert "fusion_in_frame" in update["value"]
    assert _SV_CAVEAT in update["value"]


def test_caveats_render_alongside_the_other_cautions_not_instead_of_them():
    """Licence and coverage warnings must survive the addition — they share one markdown box."""
    update = gradio_ui._status_caution_md(
        _result(
            {"KRAS": {"mutation": _freq(90.0)}},
            caveats=[_SV_CAVEAT],
            unavailable_modalities=["expression"],
            citation={
                "study_terms": {
                    "commercial_use": "restricted",
                    "commercial_use_basis": {"instrument": "Broad DepMap/CCLE Portal Terms"},
                }
            },
        )
    )
    text = update["value"]
    assert "expression" in text
    assert _SV_CAVEAT in text
    assert "Licensing" in text


def test_no_caveats_leaves_the_box_untouched():
    """An answer with nothing to warn about must not grow an empty banner."""
    update = gradio_ui._status_caution_md(_result({"KRAS": {"mutation": _freq(90.0)}}))
    assert update["visible"] is False