b5b01d50c2
* feat: document sync / QuickJS panel / async dispatch modes in DELEGATION_STRATEGY * feat: surface QuickJS panel dispatches in TUI * fix: finalize running panel dispatches on turn cancellation * fix: replace panel widget cancel path with public finalize API * fix: drop dead isinstance guard on panel dispatch duration_ms * fix: re-arm panel widget when a new dispatch arrives after finalize * fix: stop panel timer when finalize_running finds zero running rows * fix: register panel widget in cleanup dict before awaiting mount
195 lines
7.2 KiB
Python
195 lines
7.2 KiB
Python
"""Unit tests for PanelWidget (TUI in-eval fan-out live view).
|
|
|
|
Widget-mount / compose paths need a Textual App context and are exercised
|
|
through the interactive TUI end-to-end. Compositor-free tests cover
|
|
construction and state-transition helpers; ``TestPanelWidgetReentry`` runs
|
|
against a real Textual harness to probe the ``_is_active`` latch behaviour
|
|
after finalize (the ``Promise.allSettled`` → retry-failed-subset scenario).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("textual")
|
|
|
|
|
|
class TestPanelWidgetState:
|
|
"""PanelWidget state transitions independent of the compositor."""
|
|
|
|
def test_construction(self):
|
|
from EvoScientist.cli.widgets.panel_widget import PanelWidget
|
|
|
|
w = PanelWidget("ci_eval_1")
|
|
assert w.eval_id == "ci_eval_1"
|
|
assert w.dispatch_count == 0
|
|
assert w._is_active is True
|
|
|
|
def test_summary_counts_all_running(self):
|
|
from EvoScientist.cli.widgets.panel_widget import PanelWidget, _DispatchRow
|
|
|
|
w = PanelWidget("e1")
|
|
w._rows["d1"] = _DispatchRow("innovator", "a")
|
|
w._rows["d2"] = _DispatchRow("pragmatist", "b")
|
|
running, ok, err = w._summary_counts()
|
|
assert (running, ok, err) == (2, 0, 0)
|
|
|
|
def test_summary_counts_mixed(self):
|
|
from EvoScientist.cli.widgets.panel_widget import PanelWidget, _DispatchRow
|
|
|
|
w = PanelWidget("e1")
|
|
r1 = _DispatchRow("innovator", "a")
|
|
r2 = _DispatchRow("pragmatist", "b")
|
|
r3 = _DispatchRow("critic", "c")
|
|
r1._status = "ok"
|
|
r2._status = "err"
|
|
w._rows["d1"] = r1
|
|
w._rows["d2"] = r2
|
|
w._rows["d3"] = r3
|
|
running, ok, err = w._summary_counts()
|
|
assert (running, ok, err) == (1, 1, 1)
|
|
|
|
|
|
class TestDispatchRow(unittest.TestCase):
|
|
"""_DispatchRow state transitions."""
|
|
|
|
def test_construction_sets_running(self):
|
|
from EvoScientist.cli.widgets.panel_widget import _DispatchRow
|
|
|
|
row = _DispatchRow("idea-brainstorm", "innovator voice")
|
|
assert row._subagent_type == "idea-brainstorm"
|
|
assert row._label == "innovator voice"
|
|
assert row._status == "running"
|
|
assert row._duration_ms is None
|
|
|
|
def test_elapsed_display_uses_recorded_duration(self):
|
|
from EvoScientist.cli.widgets.panel_widget import _DispatchRow
|
|
|
|
row = _DispatchRow("x", "y")
|
|
row._duration_ms = 2500
|
|
display = row._elapsed_display()
|
|
assert "2.5" in display
|
|
|
|
|
|
class TestPanelWidgetReentry:
|
|
"""Regression: retry-failed-subset under the same eval_id re-arms the panel.
|
|
|
|
The ``Promise.allSettled`` → retry-failed-subset pattern (encouraged by
|
|
the DELEGATION_STRATEGY guardrail) can send a second wave of
|
|
``panel_dispatch_start`` events under the same ``eval_id`` after the
|
|
panel has already finalized. ``start_dispatch`` must detect the
|
|
finalized state and undo the three effects of ``_maybe_finalize``
|
|
(latch, ``--completed`` class, interval timer) so the new row's
|
|
spinner + elapsed keep ticking and the header refreshes on completion.
|
|
"""
|
|
|
|
async def _boot_panel(self):
|
|
from textual.app import App, ComposeResult
|
|
|
|
from EvoScientist.cli.widgets.panel_widget import PanelWidget
|
|
|
|
class _PanelApp(App[None]):
|
|
def compose(self) -> ComposeResult:
|
|
yield PanelWidget("eval-42")
|
|
|
|
app = _PanelApp()
|
|
return app
|
|
|
|
def _header_text(self, panel) -> str:
|
|
from textual.widgets import Static
|
|
|
|
header = panel.query_one(".panel-header", Static)
|
|
if header.size.height == 0:
|
|
return ""
|
|
return "".join(seg.text for seg in header.render_line(0)).strip()
|
|
|
|
async def test_reentry_after_finalize_rearms_panel(self):
|
|
from EvoScientist.cli.widgets.panel_widget import PanelWidget
|
|
|
|
app = await self._boot_panel()
|
|
async with app.run_test(size=(120, 20)) as pilot:
|
|
panel = app.query_one(PanelWidget)
|
|
await pilot.pause()
|
|
|
|
# Wave 1: two dispatches, one settles ok, one settles err.
|
|
await panel.start_dispatch("d1", "innovator", "brainstorm a")
|
|
await panel.start_dispatch("d2", "pragmatist", "brainstorm b")
|
|
await pilot.pause()
|
|
panel.complete_dispatch("d1", 1200)
|
|
panel.fail_dispatch("d2", 800, "boom")
|
|
await pilot.pause()
|
|
|
|
# Baseline: finalize fired.
|
|
assert panel._is_active is False
|
|
assert panel._timer_handle is None
|
|
assert panel.has_class("--completed")
|
|
header_after_wave1 = self._header_text(panel)
|
|
assert "1 done" in header_after_wave1
|
|
assert "1 failed" in header_after_wave1
|
|
assert "running" not in header_after_wave1
|
|
|
|
# Wave 2: eval retries the failed subset under the same eval_id.
|
|
await panel.start_dispatch("d3", "pragmatist", "brainstorm b retry")
|
|
await pilot.pause()
|
|
|
|
# Panel re-armed: latch flipped, timer restarted, class dropped.
|
|
assert panel._is_active is True
|
|
assert panel._timer_handle is not None
|
|
assert not panel.has_class("--completed")
|
|
|
|
# Header shows the running count again.
|
|
header_during_wave2 = self._header_text(panel)
|
|
assert "1 running" in header_during_wave2
|
|
assert "1 done" in header_during_wave2
|
|
assert "1 failed" in header_during_wave2
|
|
|
|
# d3 completes: header refreshes to 2 done, panel re-finalizes.
|
|
panel.complete_dispatch("d3", 900)
|
|
await pilot.pause()
|
|
header_after_wave2 = self._header_text(panel)
|
|
assert "2 done" in header_after_wave2
|
|
assert "1 failed" in header_after_wave2
|
|
assert "running" not in header_after_wave2
|
|
assert panel._is_active is False
|
|
assert panel._timer_handle is None
|
|
assert panel.has_class("--completed")
|
|
|
|
|
|
class TestPanelWidgetFinalizeEmpty:
|
|
"""Regression: ``finalize_running`` stops the timer even with zero
|
|
running rows.
|
|
|
|
The timer is armed by ``on_mount`` (first-dispatch case) or re-armed
|
|
by ``start_dispatch`` (retry-failed-subset case) before any row lands
|
|
in the "running" state. If cancel arrives before the first row is
|
|
registered, ``finalize_running``'s loop body never runs, so
|
|
``_maybe_finalize`` must be called unconditionally after the loop.
|
|
"""
|
|
|
|
async def test_finalize_running_stops_timer_with_no_rows(self):
|
|
from textual.app import App, ComposeResult
|
|
|
|
from EvoScientist.cli.widgets.panel_widget import PanelWidget
|
|
|
|
class _PanelApp(App[None]):
|
|
def compose(self) -> ComposeResult:
|
|
yield PanelWidget("eval-empty")
|
|
|
|
app = _PanelApp()
|
|
async with app.run_test(size=(120, 20)) as pilot:
|
|
panel = app.query_one(PanelWidget)
|
|
await pilot.pause()
|
|
|
|
# Timer armed by on_mount, no dispatches yet.
|
|
assert panel._is_active is True
|
|
assert panel._timer_handle is not None
|
|
assert panel._rows == {}
|
|
|
|
panel.finalize_running()
|
|
|
|
assert panel._is_active is False
|
|
assert panel._timer_handle is None
|
|
assert panel.has_class("--completed")
|