Files
hqt/tests/test_tui.py
T
felixm 9379e1523c Add vim list nav, fix window-title padding toggle, widen title spacing
TUI:
- VimListView: j/k now move down/up in the project and session lists
  (arrow keys still work); used by both list widgets.

tmux status bar:
- Widen the gap between window cells via a 2-space window-status-separator
  (WINDOW_STATUS_SEPARATOR).
- Fix the current-window cell toggling between padded " 1: name " and
  unpadded "1: name". The per-window status format is static, but
  set_window_label re-asserted it on every 3s poll — letting a competing
  writer (e.g. a stale TUI process running older code with a differently
  padded format) flip the cell each cycle. The format is now owned by a
  single write-once path (new_window at creation, apply_theme for existing
  windows); the poll updates only the dynamic @hqt_label.

Also lands pre-staged scaffolding: AGENTS.md quality gate, TODO.md backlog,
and ty dev dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:43:47 -04:00

1133 lines
43 KiB
Python

import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from hqt.tui.app import HqtApp
@pytest.fixture(autouse=True)
def mock_services(tmp_path, monkeypatch):
"""Mock DB and services so the TUI can mount without real DB/tmux."""
from hqt.config import Settings
settings = Settings(db_path=tmp_path / "test.db")
monkeypatch.setattr("hqt.tui.app.get_settings", lambda: settings)
@pytest.mark.asyncio
async def test_app_mounts():
app = HqtApp()
async with app.run_test() as pilot:
assert app.title == "HQ Terminal"
@pytest.mark.asyncio
async def test_no_header_bar():
"""The 'HQ Terminal' Header bar is removed for a cleaner, chrome-free top row."""
from textual.widgets import Header
app = HqtApp()
async with app.run_test() as pilot:
assert len(app.query(Header)) == 0
@pytest.mark.asyncio
async def test_app_has_project_and_session_panels():
app = HqtApp()
async with app.run_test() as pilot:
from hqt.tui.widgets.project_list import ProjectList
from hqt.tui.widgets.session_list import SessionList
assert app.query_one(ProjectList) is not None
assert app.query_one(SessionList) is not None
@pytest.mark.asyncio
async def test_session_refresh_no_duplicate_ids():
"""Regression test: rapid session refreshes must not crash with DuplicateIds."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project, Harness, Session
proj = Project(name="test", path="/tmp/test")
app._db_session.add(proj)
app._db_session.flush()
harness = app._db_session.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-test-dup",
archived=False,
)
app._db_session.add(sess)
app._db_session.commit()
app._selected_project_id = proj.id
# Call refresh multiple times rapidly (simulates poll timer overlap)
await app._refresh_sessions()
await app._refresh_sessions()
await app._refresh_sessions()
# Wait for a poll timer cycle
await asyncio.sleep(4)
assert app.is_running
@pytest.mark.asyncio
async def test_session_selection_preserved_across_refresh():
"""Selection must survive poll-driven refreshes."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project, Harness, Session
from hqt.tui.widgets.session_list import SessionList
proj = Project(name="test", path="/tmp/test-sel")
app._db_session.add(proj)
app._db_session.flush()
harness = app._db_session.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-test-sel",
archived=False,
)
app._db_session.add(sess)
app._db_session.commit()
app._selected_project_id = proj.id
await app._refresh_sessions()
await pilot.pause()
sl = app.query_one(SessionList)
lv = sl.query_one("#session-list")
lv.focus()
await pilot.press("down")
await pilot.pause()
assert sl.get_selected_session_id() == sess.id
# Refresh should preserve selection
await app._refresh_sessions()
await pilot.pause()
assert sl.get_selected_session_id() == sess.id
# ---------------------------------------------------------------------------
# Task 7: SessionList widget renders status in label
# ---------------------------------------------------------------------------
def _make_session_info(session_id: int, name: str, harness_name: str, status: str, alive: bool):
"""Build a minimal SessionInfo-like object for widget testing."""
from hqt.sessions.service import SessionInfo
harness = MagicMock()
harness.name = harness_name
session = MagicMock()
session.id = session_id
session.nickname = name
session.tmux_session_name = f"hqt-{session_id}"
session.harness = harness
return SessionInfo(session=session, alive=alive, status=status)
@pytest.mark.asyncio
async def test_session_list_label_includes_status():
"""SessionList rows must include status text in their label."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.session_list import SessionList
from textual.widgets import Label
sl = app.query_one(SessionList)
infos = [
_make_session_info(1, "mywork", "claude", "working", True),
_make_session_info(2, "idle-sess", "codex", "idle", True),
_make_session_info(3, "dead-sess", "kiro", "dead", False),
]
await sl.refresh_sessions(infos)
await pilot.pause()
lv = sl.query_one("#session-list")
label_texts = [str(child.query_one(Label).render()) for child in lv.children]
# Every label must contain the status word
assert any("working" in t for t in label_texts), f"Expected 'working' in labels: {label_texts}"
assert any("idle" in t for t in label_texts), f"Expected 'idle' in labels: {label_texts}"
assert any("dead" in t for t in label_texts), f"Expected 'dead' in labels: {label_texts}"
# Symbols: working → ◐, idle → ●, dead → ○
assert any("" in t for t in label_texts), f"Expected '' in labels: {label_texts}"
assert any("" in t for t in label_texts), f"Expected '' in labels: {label_texts}"
@pytest.mark.asyncio
async def test_session_list_waiting_symbol():
"""SessionList: 'waiting' status uses ◉ symbol."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.session_list import SessionList
from textual.widgets import Label
sl = app.query_one(SessionList)
infos = [_make_session_info(1, "waiting-sess", "claude", "waiting", True)]
await sl.refresh_sessions(infos)
await pilot.pause()
lv = sl.query_one("#session-list")
label_texts = [str(child.query_one(Label).render()) for child in lv.children]
assert any("waiting" in t for t in label_texts)
assert any("" in t for t in label_texts)
# ---------------------------------------------------------------------------
# Task 8: Stop session binding + action
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stop_session_action_calls_service_and_refreshes():
"""action_stop_session calls stop_session on the service then refreshes."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project, Harness, Session
from hqt.tui.widgets.session_list import SessionList
proj = Project(name="stop-proj", path="/tmp/stop-proj")
app._db_session.add(proj)
app._db_session.flush()
harness = app._db_session.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-stop-test",
archived=False,
)
app._db_session.add(sess)
app._db_session.commit()
app._selected_project_id = proj.id
await app._refresh_sessions()
await pilot.pause()
# Select the session row
sl = app.query_one(SessionList)
lv = sl.query_one("#session-list")
lv.focus()
await pilot.press("down")
await pilot.pause()
assert sl.get_selected_session_id() == sess.id
# Replace service with a mock that records calls
mock_service = MagicMock()
mock_service.stop_session = AsyncMock()
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
# Invoke the action
await app.run_action("stop_session")
await pilot.pause()
mock_service.stop_session.assert_awaited_once_with(sess.id)
mock_service.list_sessions.assert_awaited()
@pytest.mark.asyncio
async def test_stop_session_binding_exists():
"""'s' key must be declared in BINDINGS."""
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert "s" in bindings
assert bindings["s"].action == "stop_session"
# ---------------------------------------------------------------------------
# Task 8: new_session on_dismiss — sequential create then refresh (Fix 2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_new_session_on_dismiss_sequential_create_then_refresh():
"""on_dismiss path: create_session completes before list_sessions is called.
Exercises the real action_new_session / on_dismiss path in app.py so that
reverting to two separate workers would break this test.
"""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project
proj = Project(name="seq-proj", path="/tmp/seq-proj")
app._db_session.add(proj)
app._db_session.flush()
app._db_session.commit()
app._selected_project_id = proj.id
call_order: list[str] = []
async def fake_create(project_id, harness_name, nickname, model):
# Brief yield so that, if two separate workers were used, the list
# worker would be able to start and record "list" before this
# completes — exposing the regression.
await asyncio.sleep(0.05)
call_order.append("create")
from hqt.sessions.service import CreateSessionResult
return CreateSessionResult(session=MagicMock(), spawn_ok=True, spawn_error="")
async def fake_list(project_id):
call_order.append("list")
return []
mock_service = MagicMock()
mock_service.create_session = AsyncMock(side_effect=fake_create)
mock_service.list_sessions = AsyncMock(side_effect=fake_list)
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
# Patch discover_harnesses so the early-return guard doesn't fire.
from unittest.mock import patch
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
app.action_new_session()
await pilot.pause()
# The NewSessionScreen is now on top; dismiss it with a valid result tuple.
app.screen.dismiss(("claude", "mynick", None))
await pilot.pause()
# Wait for the worker spawned by on_dismiss to finish deterministically.
await app.workers.wait_for_complete()
await pilot.pause()
assert call_order == ["create", "list"], f"Expected create before list, got: {call_order}"
# ---------------------------------------------------------------------------
# Fix 3: TUI notifies with error when spawn fails
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_new_session_on_dismiss_notifies_on_spawn_failure():
"""on_dismiss: failing spawn → app.notify called with severity='error' containing error text."""
from unittest.mock import patch, AsyncMock, MagicMock
from hqt.sessions.service import CreateSessionResult
from hqt.db.models import Session as DBSession, Harness
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project
proj = Project(name="fail-proj", path="/tmp/fail-proj")
app._db_session.add(proj)
app._db_session.flush()
app._db_session.commit()
app._selected_project_id = proj.id
# Build a minimal fake Session object
harness_row = app._db_session.query(Harness).first()
fake_sess = DBSession(
project_id=proj.id,
harness_id=harness_row.id,
tmux_session_name="hqt-fail",
archived=False,
)
app._db_session.add(fake_sess)
app._db_session.flush()
fail_result = CreateSessionResult(
session=fake_sess,
spawn_ok=False,
spawn_error="bash: claude: command not found\nsome extra line",
)
mock_service = MagicMock()
mock_service.create_session = AsyncMock(return_value=fail_result)
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
notify_calls = []
def capture_notify(msg, **kwargs):
notify_calls.append((msg, kwargs))
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
with patch.object(app, "notify", side_effect=capture_notify):
app.action_new_session()
await pilot.pause()
app.screen.dismiss(("claude", "mynick", None))
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
# Should have notified with severity="error"
error_notifs = [(m, kw) for m, kw in notify_calls if kw.get("severity") == "error"]
assert len(error_notifs) >= 1, f"Expected error notification, got: {notify_calls}"
# Message should contain the first line of the error
msg_text = error_notifs[0][0]
assert "command not found" in msg_text or "claude" in msg_text, f"Unexpected message: {msg_text}"
@pytest.mark.asyncio
async def test_new_session_on_dismiss_no_notify_on_success():
"""on_dismiss: successful spawn → no error notify."""
from unittest.mock import patch, AsyncMock, MagicMock
from hqt.sessions.service import CreateSessionResult
from hqt.db.models import Session as DBSession, Harness
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project
proj = Project(name="ok-proj", path="/tmp/ok-proj")
app._db_session.add(proj)
app._db_session.flush()
app._db_session.commit()
app._selected_project_id = proj.id
harness_row = app._db_session.query(Harness).first()
fake_sess = DBSession(
project_id=proj.id,
harness_id=harness_row.id,
tmux_session_name="hqt-ok",
archived=False,
)
app._db_session.add(fake_sess)
app._db_session.flush()
ok_result = CreateSessionResult(session=fake_sess, spawn_ok=True, spawn_error="")
mock_service = MagicMock()
mock_service.create_session = AsyncMock(return_value=ok_result)
mock_service.list_sessions = AsyncMock(return_value=[])
mock_service.sync_window_labels = AsyncMock(return_value=[])
app._session_service = mock_service
notify_calls = []
def capture_notify(msg, **kwargs):
notify_calls.append((msg, kwargs))
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
with patch.object(app, "notify", side_effect=capture_notify):
app.action_new_session()
await pilot.pause()
app.screen.dismiss(("claude", "mynick", None))
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
error_notifs = [(m, kw) for m, kw in notify_calls if kw.get("severity") == "error"]
assert len(error_notifs) == 0, f"Expected no error notification, got: {error_notifs}"
# ---------------------------------------------------------------------------
# NewSessionScreen: keyboard selection of a harness
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_attach_enter_binding_disabled_in_modal():
"""Root cause: the app binds Enter -> attach_session with priority=True, so
it fires app-wide — including inside modal dialogs, where it swallowed Enter
before the dialog's widgets could see it. check_action must disable the
action while a modal screen is active so Enter falls through to the dialog."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
# On the main screen the attach binding is live.
assert app.check_action("attach_session", ()) is True
app.push_screen(NewSessionScreen(["claude", "codex"]))
await pilot.pause()
# With a modal on top, the binding is disabled so Enter reaches the modal.
assert app.check_action("attach_session", ()) is False
@pytest.mark.asyncio
async def test_new_session_enter_selects_highlighted_harness():
"""Keyboard: open the dropdown, arrow to the 2nd harness, Enter confirms it.
Regression: two bugs made this mouse-only. (1) The app's priority Enter
binding intercepted Enter inside the modal (fixed via check_action). (2)
Select defaulted to allow_blank=True, prepending a phantom blank option so
Down/Enter landed one row off. With both fixed, Down then Enter selects the
harness the user navigated to.
"""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Select
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"]))
await pilot.pause()
sel = app.screen.query_one("#harness-select", Select)
sel.focus()
await pilot.pause()
await pilot.press("enter") # open the dropdown
await pilot.pause()
await pilot.press("down") # move to the second harness
await pilot.pause()
await pilot.press("enter") # confirm the highlighted harness
await pilot.pause()
assert sel.value == "codex", f"expected 'codex', got {sel.value!r}"
@pytest.mark.asyncio
async def test_new_session_enter_without_navigation_selects_first_harness():
"""Pressing Enter to open then Enter to confirm selects the first harness,
not a blank — so the dialog yields a usable result with two keystrokes."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Select
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"]))
await pilot.pause()
sel = app.screen.query_one("#harness-select", Select)
sel.focus()
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert sel.value == "claude", f"expected 'claude', got {sel.value!r}"
assert sel.value != Select.BLANK
@pytest.mark.asyncio
async def test_new_session_enter_in_input_submits_dialog():
"""Pressing Enter in a text field submits the dialog with the current
harness selection — completing the flow from the keyboard, no mouse."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Input
results: list = []
app.push_screen(NewSessionScreen(["claude", "codex"]), results.append)
await pilot.pause()
nick = app.screen.query_one("#nickname-input", Input)
nick.focus()
nick.value = "mywork"
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert results == [("claude", "mywork", None)], f"got {results}"
# ---------------------------------------------------------------------------
# ProjectFormScreen: shared add/edit form
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_project_form_prefills_initial_values():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.project_form import ProjectFormScreen
from textual.widgets import Input
app.push_screen(
ProjectFormScreen(
title="Edit Project",
initial_name="myproj",
initial_path="/tmp/myproj",
)
)
await pilot.pause()
assert app.screen.query_one("#name-input", Input).value == "myproj"
assert app.screen.query_one("#path-input", Input).value == "/tmp/myproj"
@pytest.mark.asyncio
async def test_project_form_add_mode_defaults_name_to_basename():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.project_form import ProjectFormScreen
from textual.widgets import Button, Input
results = []
app.push_screen(ProjectFormScreen(), results.append)
await pilot.pause()
app.screen.query_one("#path-input", Input).value = "/tmp/somerepo"
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("somerepo", "/tmp/somerepo")]
@pytest.mark.asyncio
async def test_project_list_get_selected_project_id():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.project_list import ProjectList
p = app._project_service.create("selproj", "/tmp/selproj")
app._load_projects()
await app.workers.wait_for_complete()
await pilot.pause()
pl = app.query_one(ProjectList)
lv = pl.query_one("#project-list")
lv.focus()
await pilot.press("down")
await pilot.pause()
assert pl.get_selected_project_id() == p.id
# ---------------------------------------------------------------------------
# Edit project: binding + action
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_edit_project_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert "e" in bindings
assert bindings["e"].action == "edit_project"
@pytest.mark.asyncio
async def test_edit_project_no_selection_warns():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from unittest.mock import patch
notify_calls = []
with patch.object(
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
):
await app.run_action("edit_project")
await pilot.pause()
assert any(
kw.get("severity") == "warning" for _, kw in notify_calls
), f"Expected warning notification, got: {notify_calls}"
@pytest.mark.asyncio
async def test_edit_project_opens_prefilled_form_and_updates():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from textual.widgets import Input
from hqt.tui.screens.project_form import ProjectFormScreen
from hqt.tui.widgets.project_list import ProjectList
p = app._project_service.create("oldname", "/tmp/oldpath")
app._load_projects()
await app.workers.wait_for_complete()
await pilot.pause()
pl = app.query_one(ProjectList)
pl.query_one("#project-list").focus()
await pilot.press("down")
await pilot.pause()
assert pl.get_selected_project_id() == p.id
await app.run_action("edit_project")
await pilot.pause()
assert isinstance(app.screen, ProjectFormScreen)
assert app.screen.query_one("#name-input", Input).value == "oldname"
assert app.screen.query_one("#path-input", Input).value == "/tmp/oldpath"
app.screen.dismiss(("newname", "/tmp/newpath"))
await pilot.pause()
refreshed = app._project_service.get(p.id)
assert refreshed.name == "newname"
assert refreshed.path == "/tmp/newpath"
@pytest.mark.asyncio
async def test_edit_project_duplicate_path_notifies_error():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from unittest.mock import patch
from hqt.tui.widgets.project_list import ProjectList
app._project_service.create("first", "/tmp/first")
p2 = app._project_service.create("second", "/tmp/second")
app._load_projects()
await app.workers.wait_for_complete()
await pilot.pause()
pl = app.query_one(ProjectList)
pl.query_one("#project-list").focus()
# Two items: press down twice to land on the second project
await pilot.press("down")
await pilot.press("down")
await pilot.pause()
assert pl.get_selected_project_id() == p2.id
notify_calls = []
with patch.object(
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
):
await app.run_action("edit_project")
await pilot.pause()
app.screen.dismiss(("second", "/tmp/first"))
await pilot.pause()
assert any(
kw.get("severity") == "error" and "already uses path" in msg
for msg, kw in notify_calls
), f"Expected duplicate-path error notification, got: {notify_calls}"
# DB unchanged
assert app._project_service.get(p2.id).path == "/tmp/second"
# ---------------------------------------------------------------------------
# Catppuccin Frappé theme: full definition
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_frappe_theme_fully_applied():
app = HqtApp()
async with app.run_test() as pilot:
assert app.current_theme.name == "catppuccin-frappe"
variables = app.get_css_variables()
# Spot-check: explicit values, not Textual auto-derivations
assert variables["primary"] == "#8CAAEE" # Blue
assert variables["background"] == "#292C3C" # Mantle
assert variables["surface"] == "#414559" # Surface0
assert variables["border"] == "#babbf1" # Lavender
assert variables["footer-background"] == "#51576d" # Surface1
assert variables["input-cursor-background"] == "#f2d5cf" # Rosewater
@pytest.mark.asyncio
async def test_primary_accent_resolves_to_blue_in_css():
"""Regression: a $primary-driven CSS rule must resolve to Frappé Blue in
actual rendered styles. The theme variable being correct is not enough —
if the theme is applied before it is registered, CSS caches a stale
$primary (Mauve) even while get_css_variables() reports Blue."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.project_list import ProjectList
await pilot.pause()
pl = app.query_one(ProjectList)
# styles.tcss sets `border-right: solid $primary` on ProjectList.
_border_style, color = pl.styles.border_right
assert color.hex == "#8CAAEE", f"expected Frappé Blue, got {color.hex}"
@pytest.mark.asyncio
async def test_list_panels_use_dark_background_not_surface():
"""The ListView bodies must use the dark $background, not Textual's default
ListView $surface (Surface0 #414559). With $surface they paint the whole
panel a light grey-blue that clashes with the Catppuccin background."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.project_list import ProjectList
from hqt.tui.widgets.session_list import SessionList
await pilot.pause()
pbg = app.query_one(ProjectList).query_one("#project-list").styles.background
sbg = app.query_one(SessionList).query_one("#session-list").styles.background
assert pbg.hex == "#292C3C", f"project list bg {pbg.hex}, expected $background"
assert sbg.hex == "#292C3C", f"session list bg {sbg.hex}, expected $background"
@pytest.mark.asyncio
async def test_project_form_dialog_styled():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.project_form import ProjectFormScreen
app.push_screen(ProjectFormScreen())
await pilot.pause()
dialog = app.screen.query_one("#project-form-dialog")
assert dialog.styles.width.value == 60
assert dialog.styles.border_top[0] == "solid"
# $surface (Frappé Surface0) — the fill that makes the dialog opaque
# against the modal backdrop; guards against silent removal.
assert dialog.styles.background.hex == "#414559"
@pytest.mark.asyncio
async def test_dialog_buttons_are_flat_and_side_by_side():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.project_form import ProjectFormScreen
from textual.widgets import Button
app.push_screen(ProjectFormScreen())
await pilot.pause()
ok = app.screen.query_one("#ok-btn", Button)
cancel = app.screen.query_one("#cancel-btn", Button)
# Flat: a single row, not the default 3-row beveled block.
assert ok.styles.height.value == 1
assert cancel.styles.height.value == 1
# Primary stays filled Blue; Cancel is a ghost (no fill).
assert ok.styles.background.hex == "#8CAAEE"
assert ok.styles.color.hex == "#292C3C" # $background, dark text on blue
assert cancel.styles.background.a == 0 # transparent
# Laid out on one row, not stacked vertically.
assert ok.region.y == cancel.region.y
# ---------------------------------------------------------------------------
# Dialog button focus must be visibly distinct (Tab-cycling)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dialog_button_focus_is_visibly_highlighted():
"""Tabbing onto OK/Cancel must show a clear highlight, not just bold text.
The buttons drop Textual's default border + reverse focus styling, so a
focused button was nearly indistinguishable from an unfocused one. A focused
button now gets a Peach ($accent) fill with dark text — overriding each
button's id-level background so the highlight is visible on both OK and
Cancel."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.project_form import ProjectFormScreen
from textual.widgets import Button
app.push_screen(ProjectFormScreen())
await pilot.pause()
# Unfocused: OK is blue-dominant, Cancel is transparent.
for btn_id in ("#ok-btn", "#cancel-btn"):
btn = app.screen.query_one(btn_id, Button)
btn.focus()
await pilot.pause()
bg = btn.styles.background
# $accent (Frappé Peach #ef9f76) fill — peach is red-dominant, so the
# focused button is unmistakably distinct from the blue/ghost defaults.
# (Textual adds a small focus background-tint, so allow ±2 per channel.)
assert abs(bg.r - 0xEF) <= 2 and abs(bg.g - 0x9F) <= 2 and abs(bg.b - 0x76) <= 2, (
f"{btn_id} focus background {bg.hex}, expected ~Peach #EF9F76"
)
assert bg.r > bg.b, f"{btn_id} focus highlight should be peach, not blue ({bg.hex})"
assert btn.styles.color.hex == "#292C3C", (
f"{btn_id} focus text color {btn.styles.color.hex}, expected dark"
)
# ---------------------------------------------------------------------------
# Auto-select first project on start; first session + per-project memory
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_first_project_selected_on_load():
"""If a project exists, the first is selected on load with no key press."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.project_list import ProjectList
app._project_service.create("p1", "/tmp/p1-auto")
app._project_service.create("p2", "/tmp/p2-auto")
app._load_projects()
await app.workers.wait_for_complete()
await pilot.pause()
pl = app.query_one(ProjectList)
first_id = pl.query_one("#project-list").children[0].data
assert pl.get_selected_project_id() == first_id
assert app._selected_project_id == first_id
@pytest.mark.asyncio
async def test_first_session_selected_and_remembered_per_project():
"""First session is auto-selected per project; switching projects restores
each project's previously selected session."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project, Harness, Session
from hqt.tui.widgets.project_list import ProjectSelected
from hqt.tui.widgets.session_list import SessionList
h = app._db_session.query(Harness).first()
pa = Project(name="A", path="/tmp/proj-A")
pb = Project(name="B", path="/tmp/proj-B")
app._db_session.add_all([pa, pb])
app._db_session.flush()
a1 = Session(project_id=pa.id, harness_id=h.id, tmux_session_name="hqt-a1", archived=False)
a2 = Session(project_id=pa.id, harness_id=h.id, tmux_session_name="hqt-a2", archived=False)
b1 = Session(project_id=pb.id, harness_id=h.id, tmux_session_name="hqt-b1", archived=False)
app._db_session.add_all([a1, a2, b1])
app._db_session.commit()
sl = app.query_one(SessionList)
# Enter project A: first session auto-selected.
app.on_project_selected(ProjectSelected(pa.id))
await app.workers.wait_for_complete()
await pilot.pause()
assert sl.get_selected_session_id() == a1.id
# Move selection to the second session in A.
lv = sl.query_one("#session-list")
lv.focus()
await pilot.press("down")
await pilot.pause()
assert sl.get_selected_session_id() == a2.id
# Switch to project B: its first session is auto-selected.
app.on_project_selected(ProjectSelected(pb.id))
await app.workers.wait_for_complete()
await pilot.pause()
assert sl.get_selected_session_id() == b1.id
# Switch back to A: the previously selected session (a2) is restored.
app.on_project_selected(ProjectSelected(pa.id))
await app.workers.wait_for_complete()
await pilot.pause()
assert sl.get_selected_session_id() == a2.id
# ---------------------------------------------------------------------------
# Status symbol colors + harness-name markup escape
# ---------------------------------------------------------------------------
def test_format_session_text_colors_and_escapes():
from hqt.tui.widgets.session_list import format_session_text
text = format_session_text("mywork", "claude", "working", "")
assert text.startswith("[#a6d189]◐[/]") # Green symbol
assert "\\[claude]" in text # escaped, so brackets render
assert "working" in text
def test_format_session_text_status_colors():
from hqt.tui.widgets.session_list import format_session_text
assert format_session_text("x", "h", "waiting", "").startswith("[#e5c890]") # Yellow
assert format_session_text("x", "h", "idle", "").startswith("[#81c8be]") # Teal
assert format_session_text("x", "h", "active", "").startswith("[#81c8be]") # Teal
assert format_session_text("x", "h", "dead", "").startswith("[#737994]") # Overlay0
@pytest.mark.asyncio
async def test_session_list_renders_harness_name_brackets():
"""Regression: '[claude]' must be visible, not swallowed as a markup tag."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from textual.widgets import Label
from hqt.tui.widgets.session_list import SessionList
sl = app.query_one(SessionList)
infos = [_make_session_info(1, "mywork", "claude", "working", True)]
await sl.refresh_sessions(infos)
await pilot.pause()
lv = sl.query_one("#session-list")
texts = [str(child.query_one(Label).render()) for child in lv.children]
assert any("[claude]" in t for t in texts), f"harness name missing: {texts}"
# ---------------------------------------------------------------------------
# RenameSessionScreen: prefill + submit
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rename_screen_prefills_current_nickname():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.rename_session import RenameSessionScreen
from textual.widgets import Input
app.push_screen(RenameSessionScreen(initial_nickname="mywork"))
await pilot.pause()
assert app.screen.query_one("#nickname-input", Input).value == "mywork"
@pytest.mark.asyncio
async def test_rename_screen_ok_returns_stripped_value():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.rename_session import RenameSessionScreen
from textual.widgets import Button, Input
results = []
app.push_screen(RenameSessionScreen(initial_nickname=""), results.append)
await pilot.pause()
app.screen.query_one("#nickname-input", Input).value = " renamed "
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == ["renamed"]
@pytest.mark.asyncio
async def test_rename_screen_cancel_returns_none():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.rename_session import RenameSessionScreen
from textual.widgets import Button
results = []
app.push_screen(RenameSessionScreen(initial_nickname="x"), results.append)
await pilot.pause()
app.screen.query_one("#cancel-btn", Button).press()
await pilot.pause()
assert results == [None]
# ---------------------------------------------------------------------------
# Rename session: binding + action
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rename_session_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert "r" in bindings
assert bindings["r"].action == "rename_session"
@pytest.mark.asyncio
async def test_rename_session_no_selection_warns():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from unittest.mock import patch
notify_calls = []
with patch.object(
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
):
await app.run_action("rename_session")
await pilot.pause()
assert any(
kw.get("severity") == "warning" for _, kw in notify_calls
), f"Expected warning notification, got: {notify_calls}"
@pytest.mark.asyncio
async def test_rename_session_opens_prefilled_and_updates():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project, Harness, Session
from hqt.tui.widgets.session_list import SessionList
from hqt.tui.screens.rename_session import RenameSessionScreen
from textual.widgets import Input
proj = Project(name="ren-proj", path="/tmp/ren-proj")
app._db_session.add(proj)
app._db_session.flush()
harness = app._db_session.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
nickname="before",
tmux_session_name="hqt-ren",
archived=False,
)
app._db_session.add(sess)
app._db_session.commit()
app._selected_project_id = proj.id
await app._refresh_sessions()
await pilot.pause()
sl = app.query_one(SessionList)
sl.query_one("#session-list").focus()
await pilot.press("down")
await pilot.pause()
assert sl.get_selected_session_id() == sess.id
await app.run_action("rename_session")
await pilot.pause()
assert isinstance(app.screen, RenameSessionScreen)
assert app.screen.query_one("#nickname-input", Input).value == "before"
app.screen.dismiss("after")
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
assert app._db_session.get(Session, sess.id).nickname == "after"
# ---------------------------------------------------------------------------
# Vim-style j/k navigation in the project and session lists
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_project_list_jk_navigation():
"""j/k move the highlight down/up in the project list like the arrow keys."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.project_list import ProjectList
p1 = app._project_service.create("first", "/tmp/jk-first")
p2 = app._project_service.create("second", "/tmp/jk-second")
app._load_projects()
await app.workers.wait_for_complete()
await pilot.pause()
pl = app.query_one(ProjectList)
pl.query_one("#project-list").focus()
await pilot.pause()
# j walks down to the second project...
await pilot.press("j")
await pilot.pause()
assert pl.get_selected_project_id() == p2.id
# ...and k walks back up to the first.
await pilot.press("k")
await pilot.pause()
assert pl.get_selected_project_id() == p1.id
@pytest.mark.asyncio
async def test_session_list_jk_navigation():
"""j/k move the highlight down/up in the session list like the arrow keys."""
from hqt.db.models import Harness, Project, Session
from hqt.tui.widgets.session_list import SessionList
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
proj = Project(name="jk-proj", path="/tmp/jk-proj")
app._db_session.add(proj)
app._db_session.flush()
harness = app._db_session.query(Harness).first()
sessions = []
for n in range(2):
s = Session(
project_id=proj.id,
harness_id=harness.id,
nickname=f"sess-{n}",
tmux_session_name=f"hqt-jk-{n}",
archived=False,
)
app._db_session.add(s)
sessions.append(s)
app._db_session.commit()
app._selected_project_id = proj.id
# restore_selection mirrors a real project switch, which highlights the
# first session — giving j/k a defined starting point.
await app._refresh_sessions(restore_selection=True)
await pilot.pause()
sl = app.query_one(SessionList)
sl.query_one("#session-list").focus()
await pilot.pause()
await pilot.press("j")
await pilot.pause()
assert sl.get_selected_session_id() == sessions[1].id
await pilot.press("k")
await pilot.pause()
assert sl.get_selected_session_id() == sessions[0].id