Satisfy project quality gates

This commit is contained in:
2026-06-10 18:23:52 -04:00
parent 9379e1523c
commit a7851e7007
25 changed files with 1055 additions and 375 deletions
+99 -44
View File
@@ -17,7 +17,7 @@ def mock_services(tmp_path, monkeypatch):
@pytest.mark.asyncio
async def test_app_mounts():
app = HqtApp()
async with app.run_test() as pilot:
async with app.run_test():
assert app.title == "HQ Terminal"
@@ -27,16 +27,17 @@ async def test_no_header_bar():
from textual.widgets import Header
app = HqtApp()
async with app.run_test() as pilot:
async with app.run_test():
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:
async with app.run_test():
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
@@ -45,7 +46,7 @@ async def test_app_has_project_and_session_panels():
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:
async with app.run_test(size=(120, 40)):
from hqt.db.models import Project, Harness, Session
proj = Project(name="test", path="/tmp/test")
@@ -116,9 +117,12 @@ async def test_session_selection_preserved_across_refresh():
# ---------------------------------------------------------------------------
def _make_session_info(session_id: int, name: str, harness_name: str, status: str, alive: bool):
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()
@@ -150,13 +154,23 @@ async def test_session_list_label_includes_status():
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}"
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}"
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
@@ -270,7 +284,10 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
await asyncio.sleep(0.05)
call_order.append("create")
from hqt.sessions.service import CreateSessionResult
return CreateSessionResult(session=MagicMock(), spawn_ok=True, spawn_error="")
return CreateSessionResult(
session=MagicMock(), spawn_ok=True, spawn_error=""
)
async def fake_list(project_id):
call_order.append("list")
@@ -284,6 +301,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
# 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()
@@ -296,7 +314,9 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
await app.workers.wait_for_complete()
await pilot.pause()
assert call_order == ["create", "list"], f"Expected create before list, got: {call_order}"
assert call_order == ["create", "list"], (
f"Expected create before list, got: {call_order}"
)
# ---------------------------------------------------------------------------
@@ -360,11 +380,17 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure():
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}"
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}"
assert "command not found" in msg_text or "claude" in msg_text, (
f"Unexpected message: {msg_text}"
)
@pytest.mark.asyncio
@@ -394,7 +420,9 @@ async def test_new_session_on_dismiss_no_notify_on_success():
app._db_session.add(fake_sess)
app._db_session.flush()
ok_result = CreateSessionResult(session=fake_sess, spawn_ok=True, spawn_error="")
ok_result = CreateSessionResult(
session=fake_sess, spawn_ok=True, spawn_error=""
)
mock_service = MagicMock()
mock_service.create_session = AsyncMock(return_value=ok_result)
@@ -417,8 +445,12 @@ async def test_new_session_on_dismiss_no_notify_on_success():
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}"
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}"
)
# ---------------------------------------------------------------------------
@@ -469,7 +501,7 @@ async def test_new_session_enter_selects_highlighted_harness():
await pilot.press("enter") # open the dropdown
await pilot.pause()
await pilot.press("down") # move to the second harness
await pilot.press("down") # move to the second harness
await pilot.pause()
await pilot.press("enter") # confirm the highlighted harness
await pilot.pause()
@@ -609,9 +641,9 @@ async def test_edit_project_no_selection_warns():
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}"
assert any(kw.get("severity") == "warning" for _, kw in notify_calls), (
f"Expected warning notification, got: {notify_calls}"
)
@pytest.mark.asyncio
@@ -694,14 +726,14 @@ async def test_edit_project_duplicate_path_notifies_error():
@pytest.mark.asyncio
async def test_frappe_theme_fully_applied():
app = HqtApp()
async with app.run_test() as pilot:
async with app.run_test():
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["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
@@ -809,10 +841,14 @@ async def test_dialog_button_focus_is_visibly_highlighted():
# $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 (
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 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"
)
@@ -837,7 +873,9 @@ async def test_first_project_selected_on_load():
await pilot.pause()
pl = app.query_one(ProjectList)
first_id = pl.query_one("#project-list").children[0].data
first_item_id = pl.query_one("#project-list").children[0].id
assert first_item_id is not None
first_id = int(first_item_id.removeprefix("proj-"))
assert pl.get_selected_project_id() == first_id
assert app._selected_project_id == first_id
@@ -857,9 +895,24 @@ async def test_first_session_selected_and_remembered_per_project():
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)
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()
@@ -901,17 +954,21 @@ def test_format_session_text_colors_and_escapes():
text = format_session_text("mywork", "claude", "working", "")
assert text.startswith("[#a6d189]◐[/]") # Green symbol
assert "\\[claude]" in text # escaped, so brackets render
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
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
@@ -1005,9 +1062,9 @@ async def test_rename_session_no_selection_warns():
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}"
assert any(kw.get("severity") == "warning" for _, kw in notify_calls), (
f"Expected warning notification, got: {notify_calls}"
)
@pytest.mark.asyncio
@@ -1128,5 +1185,3 @@ async def test_session_list_jk_navigation():
await pilot.press("k")
await pilot.pause()
assert sl.get_selected_session_id() == sessions[0].id