File size: 4,503 Bytes
e517a43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Exports are scoped to the most recent question's run (TODO 2026-08-11 #13).

Regression cover for the stale-figure bug: a session accumulates questions in
one chat history, and the export builders swept the WHOLE history for inline
figures — so the report for question 2 embedded question 1's volcano/Hallmark
plots, which contradicted question 2's tables. `_current_question_messages`
slices the history from the last non-empty user message onward and every
export path is built from that slice.

Small synthetic messages only — no network, no Gradio runtime.
"""

import base64
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from ui_formatting import _UIFormattingMixin as U  # noqa: E402

# A valid 1x1 PNG so _extract_images' base64 decode succeeds.
_PNG_B64 = base64.b64encode(
    base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
        "AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
    )
).decode()


def _figure_msg(caption):
    return {
        "role": "assistant",
        "content": (
            f'<div style="font-style: italic;">{caption}</div>'
            f'<img src="data:image/png;base64,{_PNG_B64}" />'
        ),
    }


def _solution_msg(text):
    return {
        "role": "assistant",
        "content": (
            '<div class="solution-header">✅ Final Solution</div>'
            f'<div class="solution-content">{text}</div>'
            "<style>.x{}</style>"
        ),
    }


def _two_question_history():
    return [
        {"role": "user", "content": "Q1: shMyc vs shCntrl DE?"},
        _figure_msg("Q1 volcano"),
        _solution_msg("Q1: MYC targets down."),
        {"role": "user", "content": "Q2: tumor vs liver met?"},
        _figure_msg("Q2 heatmap"),
        _solution_msg("Q2: bile acid up."),
    ]


class TestCurrentQuestionMessages:
    def test_slices_from_last_user_message(self):
        history = _two_question_history()
        scoped = U._current_question_messages(history)
        assert scoped == history[3:]

    def test_no_user_message_returns_all(self):
        history = [_figure_msg("orphan"), _solution_msg("s")]
        assert U._current_question_messages(history) == history

    def test_empty_history(self):
        assert U._current_question_messages([]) == []

    def test_blank_user_message_ignored(self):
        # A trailing empty user turn (e.g. a stray submit) must not create an
        # empty slice that drops the real question's figures.
        history = _two_question_history() + [{"role": "user", "content": "   "}]
        assert U._current_question_messages(history) == history[3:]

    def test_continue_resume_stays_in_slice(self):
        # Continue appends only assistant messages, so a continued run's later
        # figures stay inside the current question's slice.
        history = _two_question_history() + [_figure_msg("Q2 post-continue barplot")]
        scoped = U._current_question_messages(history)
        assert scoped[0] == history[3]
        assert scoped[-1] == history[-1]

    def test_list_content_user_message(self):
        history = [
            {"role": "user", "content": [{"type": "text", "text": "Q1"}]},
            _figure_msg("f1"),
            {"role": "user", "content": [{"type": "text", "text": "Q2"}]},
            _figure_msg("f2"),
        ]
        assert U._current_question_messages(history) == history[2:]


class TestScopedExports:
    def test_extract_images_scoped_excludes_prior_question(self):
        scoped = U._current_question_messages(_two_question_history())
        images = U._extract_images(scoped)
        captions = [c for c, _ in images]
        assert captions == ["Q2 heatmap"]

    def test_unscoped_extract_shows_the_bug_surface(self):
        # Documents why scoping matters: the full history carries both figures
        # (deduped on image data here since the test PNG is identical, but the
        # first caption wins — i.e. the stale one).
        images = U._extract_images(_two_question_history())
        assert images[0][0] == "Q1 volcano"

    def test_assessment_blocks_scoped_to_current_question(self):
        u = U()
        scoped = U._current_question_messages(_two_question_history())
        blocks = u._assessment_blocks(scoped)
        headings = {h: b for h, b, _ in blocks}
        assert headings["Question"] == "Q2: tumor vs liver met?"
        assert "bile acid" in headings["Solution"]
        assert "Q1" not in headings["Question"]