"""Tests for the TodoList side-panel widget."""
from __future__ import annotations
from pathlib import Path
import pytest
from rich.text import Text
from clients.terminal.tui.widgets.todo_list import TodoList
def _plain(content: str) -> str:
"""Strip Rich markup so assertions see the visible text, not colour tags."""
return Text.from_markup(content).plain
@pytest.fixture(autouse=True)
def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Override the state dir so tests never touch ~/.navi_code."""
from clients.terminal import config
original = config.settings.state_dir
config.settings.state_dir = tmp_path
import clients.terminal.tui.settings as settings_module
settings_module._tui_settings = None
yield tmp_path
config.settings.state_dir = original
settings_module._tui_settings = None
@pytest.mark.anyio
async def test_todo_list_empty_state() -> None:
"""A fresh TodoList shows the 'No plan yet' placeholder."""
from clients.terminal.tui.tui_app import NaviCodeTui
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
# _build_content is the source of truth (never name this _render —
# it shadows Widget._render and crashes Textual's layout pass).
assert "No plan yet" in todo._build_content()
@pytest.mark.anyio
async def test_todo_list_renders_tasks_with_header() -> None:
from clients.terminal.tui.tui_app import NaviCodeTui
tasks = [
{"index": 1, "text": "step one", "status": "done", "validation": "ran tests"},
{"index": 2, "text": "step two", "status": "in_progress", "validation": ""},
{"index": 3, "text": "step three", "status": "failed", "validation": "boom"},
]
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks(tasks)
plain = _plain(todo._build_content())
assert "1/3 done" in plain
assert "step one" in plain
assert "step two" in plain
assert "(in_progress)" in plain
assert "(failed)" in plain
assert "✓ ran tests" in plain # validation note on the done step
assert "✓ boom" in plain # validation note on the failed step
@pytest.mark.anyio
async def test_todo_list_clear_resets_to_placeholder() -> None:
from clients.terminal.tui.tui_app import NaviCodeTui
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks([{"index": 1, "text": "x", "status": "pending", "validation": ""}])
assert "x" in todo._build_content()
todo.clear()
assert "No plan yet" in todo._build_content()
def test_build_content_never_named_render():
"""Guard against the Textual _render shadow pitfall: a method called _render
on a Widget subclass crashes the layout pass (AttributeError get_height)."""
assert "_render" not in TodoList.__dict__, (
"TodoList must not override _render — it shadows Widget._render "
"(Textual calls it expecting a Strip, gets a str → AttributeError)."
)
assert "_build_content" in TodoList.__dict__
@pytest.mark.anyio
async def test_status_markers_are_coloured_and_emphasised() -> None:
"""Each status gets its own marker colour, and the active step is bolded
while completed/skipped steps are dimmed — so progress is legible at a glance."""
from clients.terminal.tui.tui_app import NaviCodeTui
from clients.terminal.tui.themes import get_active_theme
tasks = [
{"index": 1, "text": "done step", "status": "done", "validation": ""},
{"index": 2, "text": "active step", "status": "in_progress", "validation": ""},
{"index": 3, "text": "failed step", "status": "failed", "validation": ""},
{"index": 4, "text": "queued step", "status": "pending", "validation": ""},
]
theme = get_active_theme()
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks(tasks)
content = todo._build_content()
# Marker icons are wrapped in their status colour.
assert f"[{theme.success.hex}]✓" in content # done
assert f"[{theme.accent.hex}]◎" in content # in_progress
assert f"[{theme.error.hex}]✗" in content # failed
assert f"[{theme.text_dim.hex}]○" in content # pending
# The active step is bold; completed steps are dimmed.
assert "bold" in content
assert "dim" in content
# Plain text still carries every step.
plain = _plain(content)
assert "active step" in plain
assert "queued step" in plain
@pytest.mark.anyio
async def test_todo_list_groups_by_status_in_progress_first_done_last() -> None:
"""Steps are grouped for display: in_progress on top, then pending, then
failed/skipped, then done at the bottom — regardless of the payload order.
Original order is preserved within each group; the header still counts all
done steps."""
from clients.terminal.tui.tui_app import NaviCodeTui
# Payload in plan order: done, pending, in_progress, done, failed, skipped.
tasks = [
{"index": 1, "text": "done one", "status": "done", "validation": ""},
{"index": 2, "text": "queued one", "status": "pending", "validation": ""},
{"index": 3, "text": "active one", "status": "in_progress", "validation": ""},
{"index": 4, "text": "done two", "status": "done", "validation": ""},
{"index": 5, "text": "boom", "status": "failed", "validation": ""},
{"index": 6, "text": "skip", "status": "skipped", "validation": ""},
]
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks(tasks)
plain = _plain(todo._build_content())
# Header counts both done steps.
assert "2/6 done" in plain
# The visible order is the display order, not the payload order.
assert plain.index("active one") < plain.index("queued one")
assert plain.index("queued one") < plain.index("boom")
assert plain.index("boom") < plain.index("done one")
assert plain.index("skip") < plain.index("done one")
assert plain.index("done one") < plain.index("done two")
# Original order preserved within the done group (done one before done two).
assert plain.index("done one") < plain.index("done two")
@pytest.mark.anyio
async def test_groups_tasks_by_milestone() -> None:
"""When tasks carry a ``milestone`` label, the panel renders milestone
headers and nests each step under its group. Groups are ordered by their
most active step's status-rank (with a stable tiebreak on first-appearance
order); the unnamed group takes part in the same ranking and renders
without a header."""
from clients.terminal.tui.tui_app import NaviCodeTui
tasks = [
{"index": 1, "text": "read spec", "status": "done", "validation": "", "milestone": "A"},
{"index": 2, "text": "write code", "status": "in_progress", "validation": "", "milestone": "A"},
{"index": 3, "text": "run tests", "status": "pending", "validation": "", "milestone": "B"},
{"index": 4, "text": "orphan step", "status": "pending", "validation": "", "milestone": ""},
]
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks(tasks)
plain = _plain(todo._build_content())
# Both milestone headers present.
assert "Milestone A" in plain
assert "Milestone B" in plain
# Ranks: A=0 (in_progress), B=1 (pending), ""=1 (pending). Stable
# tiebreak keeps first-appearance order among equal ranks, so A, then B,
# then the unnamed group.
assert plain.index("Milestone A") < plain.index("Milestone B")
# Steps nest under their group (group header precedes its steps).
assert plain.index("Milestone A") < plain.index("write code")
assert plain.index("Milestone B") < plain.index("run tests")
# The unnamed group ("orphan step") follows the named groups of equal
# or higher activity (here B is also pending, so it keeps plan position
# ahead of the unnamed block via the stable tiebreak).
assert plain.index("run tests") < plain.index("orphan step")
# Header still counts all done steps.
assert "1/4 done" in plain
@pytest.mark.anyio
async def test_milestone_groups_ordered_by_rank_done_groups_sink() -> None:
"""A fully-done milestone sinks below one with work in progress, extending
the per-step "done to the bottom" rule to the group level — regardless of
plan order in the payload."""
from clients.terminal.tui.tui_app import NaviCodeTui
# Payload in plan order A (done), B (active), C (pending).
tasks = [
{"index": 1, "text": "A done 1", "status": "done", "validation": "", "milestone": "A"},
{"index": 2, "text": "A done 2", "status": "done", "validation": "", "milestone": "A"},
{"index": 3, "text": "B active", "status": "in_progress", "validation": "", "milestone": "B"},
{"index": 4, "text": "B pending", "status": "pending", "validation": "", "milestone": "B"},
{"index": 5, "text": "C pending", "status": "pending", "validation": "", "milestone": "C"},
]
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks(tasks)
plain = _plain(todo._build_content())
# Ranks: A=3 (all done), B=0 (in_progress), C=1 (pending).
# Expected group order: B (active) -> C (pending) -> A (done, sunk).
assert plain.index("Milestone B") < plain.index("Milestone C")
assert plain.index("Milestone C") < plain.index("Milestone A")
# The done group A carries the ✓ marker (it is fully done).
a_line = next(line for line in todo._build_content().splitlines() if "Milestone A" in line)
assert "✓" in a_line
@pytest.mark.anyio
async def test_unnamed_group_ranks_with_named_groups() -> None:
"""An unnamed (no-milestone) step block ranks alongside named groups by its
own activity: an active unnamed block leads the panel while a finished
named milestone sinks below it. The unnamed block renders without a
header."""
from clients.terminal.tui.tui_app import NaviCodeTui
tasks = [
{"index": 1, "text": "A done", "status": "done", "validation": "", "milestone": "A"},
{"index": 2, "text": "orphan active", "status": "in_progress", "validation": "", "milestone": ""},
]
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks(tasks)
plain = _plain(todo._build_content())
# Ranks: ""=0 (in_progress), A=3 (done). Unnamed block leads.
assert plain.index("orphan active") < plain.index("Milestone A")
@pytest.mark.anyio
async def test_milestone_groups_equal_rank_keep_plan_order() -> None:
"""Groups of equal rank keep the plan's first-appearance order (stable
sort), so two equally-inactive milestones are not shuffled arbitrarily."""
from clients.terminal.tui.tui_app import NaviCodeTui
tasks = [
{"index": 1, "text": "A pending", "status": "pending", "validation": "", "milestone": "A"},
{"index": 2, "text": "B pending", "status": "pending", "validation": "", "milestone": "B"},
]
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks(tasks)
plain = _plain(todo._build_content())
# Both rank 1 (pending); stable tiebreak keeps plan order A -> B.
assert plain.index("Milestone A") < plain.index("Milestone B")
@pytest.mark.anyio
async def test_milestone_group_marked_done_when_all_steps_done() -> None:
"""A milestone group whose steps are all done/skipped gets a ✓ on its
header; an in-progress group does not."""
from clients.terminal.tui.tui_app import NaviCodeTui
tasks = [
{"index": 1, "text": "done one", "status": "done", "validation": "", "milestone": "A"},
{"index": 2, "text": "skipped one", "status": "skipped", "validation": "", "milestone": "A"},
{"index": 3, "text": "active", "status": "in_progress", "validation": "", "milestone": "B"},
]
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
todo = pilot.app.query_one(TodoList)
todo.set_tasks(tasks)
content = todo._build_content()
# Group A is fully done/skipped -> ✓ in its header line.
a_line = next(line for line in content.splitlines() if "Milestone A" in line)
assert "✓" in a_line
# Group B is not -> no ✓ in its header line.
b_line = next(line for line in content.splitlines() if "Milestone B" in line)
assert "✓" not in b_line
@pytest.mark.anyio
async def test_todo_panel_is_scrollable_and_wraps_list() -> None:
"""The todo lives in its own scrollable panel below the info block, not
inside StatusPanel."""
from textual.containers import ScrollableContainer
from clients.terminal.tui.tui_app import NaviCodeTui
from clients.terminal.tui.widgets.todo_list import TodoPanel
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
panel = pilot.app.query_one(TodoPanel)
assert isinstance(panel, ScrollableContainer)
# The TodoList is a child of the panel.
assert panel.query_one(TodoList) is not None
# The panel fills the remaining column height; the info block shrinks.
assert str(panel.styles.height) == "1fr"