"""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'
{caption}
' f'' ), } def _solution_msg(text): return { "role": "assistant", "content": ( '
✅ Final Solution
' f'
{text}
' "" ), } 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"]