- templates/bot_runner.py (new, no Qt): runs the bot as a child process (unbuffered, UTF-8), tails its output, keeps raw lines in bot_raw.log, and turns lines into step/log/status events. Step markers and friendly rules use specific substrings, first match wins; unmatched error lines are always shown, other unmatched lines hidden. Dry run: python dashboard/bot_runner.py prints the events the page would get. - bridge.py: delegates to bot_runner, one run at a time, restores the saved dark mode on every page load (fixes sample-data mode in PyQt), adds load_latest_report / download_report slots the page calls. - SKILL.md: skip the stack question for a PyQt project; desktop wiring steps from the bot's real output; verify steps for the host. - tests: assert-based self-check for the line logic and run loop; Seam 1 run replay (status dot, Run button, steps, log) and wired-page mode; fixture b-pyqt-bot and its Seam 2 eval (all 14 checks pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
6.9 KiB
Python
173 lines
6.9 KiB
Python
"""No-AI contract cases: events in, visible result out (see templates/contract.md)."""
|
|
import re
|
|
|
|
import pytest
|
|
from playwright.sync_api import expect
|
|
|
|
|
|
def batch(n, state):
|
|
return {"batch_id": n, "date": "2026-10-16", "state": state, "items_total": 10, "items_done": 4}
|
|
|
|
|
|
BATCHES = [batch(1, "completed"), batch(2, "completed"), batch(3, "completed"),
|
|
batch(4, "inprogress"), batch(5, "notcompleted"), batch(6, "cleared")]
|
|
|
|
|
|
def kpi(page, label, n):
|
|
return expect(page.get_by_role("group", name=label, exact=True)).to_have_text(
|
|
re.compile(rf"^\s*{n}\s*{label}\s*$"))
|
|
|
|
|
|
def test_kpi_counts_match_batch_states(page, push):
|
|
push("dashboard_data", {"batches": BATCHES})
|
|
kpi(page, "Total batches", 6)
|
|
kpi(page, "Completed", 3)
|
|
kpi(page, "In progress", 1)
|
|
kpi(page, "Not completed", 1)
|
|
|
|
|
|
def colour_name(css):
|
|
"""'rgb(r, g, b)' -> red | amber | green | blue | grey, so checks survive any palette."""
|
|
r, g, b = (int(x) / 255 for x in re.findall(r"\d+", css)[:3])
|
|
hi, lo = max(r, g, b), min(r, g, b)
|
|
if hi - lo < 0.25 * min(hi + lo, 2 - hi - lo): # low HSL saturation
|
|
return "grey"
|
|
d = hi - lo
|
|
h = 60 * (((g - b) / d) % 6 if hi == r else (b - r) / d + 2 if hi == g else (r - g) / d + 4)
|
|
return "red" if h < 20 or h >= 330 else "amber" if h < 70 else "green" if h < 175 else "blue"
|
|
|
|
|
|
def donut_segments(donut):
|
|
"""[(colour name, percent of the ring)] read from the rendered conic gradient."""
|
|
css = donut.evaluate("el => getComputedStyle(el).backgroundImage")
|
|
segs = []
|
|
for colour, positions in re.findall(r"(rgba?\([^)]*\))((?:\s+[\d.]+%)+)", css):
|
|
pos = [float(p) for p in re.findall(r"[\d.]+", positions)]
|
|
name = colour_name(colour)
|
|
if segs and segs[-1][0] == name:
|
|
segs[-1][2] = pos[-1]
|
|
else:
|
|
segs.append([name, pos[0], pos[-1]])
|
|
return [(name, round(end - start)) for name, start, end in segs if end > start]
|
|
|
|
|
|
def test_donut_and_legend_match_batch_states(page, push):
|
|
push("dashboard_data", {"batches": BATCHES})
|
|
legend = page.get_by_role("list", name="Batch status")
|
|
for row in ["Completed · 3", "In progress · 1", "Not completed · 1", "Cleared · 1"]:
|
|
expect(legend.get_by_text(row, exact=True)).to_be_visible()
|
|
donut = page.get_by_role("img", name=re.compile("^Batch status"))
|
|
expect(donut).to_have_text(re.compile(r"^\s*6\s*batches\s*$"))
|
|
assert donut_segments(donut) == [("green", 50), ("amber", 17), ("red", 17), ("grey", 17)]
|
|
|
|
|
|
def open_tab(page, name):
|
|
page.get_by_role("navigation").get_by_role("button", name=name, exact=True).click()
|
|
|
|
|
|
def test_step_list_follows_step_label(page, push):
|
|
steps = page.evaluate("STEPS")
|
|
open_tab(page, "Run")
|
|
push("step", {"label": steps[1], "pct": 40})
|
|
items = page.get_by_role("list", name="Steps").get_by_role("listitem")
|
|
expect(items).to_have_count(len(steps))
|
|
expect(items.nth(0)).to_have_text(re.compile(rf"^\s*✓\s*{re.escape(steps[0])}\s*$"))
|
|
expect(items.nth(1)).to_have_attribute("aria-current", "step")
|
|
for i in range(2, len(steps)):
|
|
expect(items.nth(i)).to_have_text(re.compile(rf"^\s*{i + 1}\s*{re.escape(steps[i])}\s*$"))
|
|
expect(page.get_by_text("40%", exact=True)).to_be_visible()
|
|
|
|
push("step", {"label": steps[-1], "pct": 100})
|
|
for i in range(len(steps)):
|
|
expect(items.nth(i)).to_have_text(re.compile(r"^\s*✓"))
|
|
|
|
|
|
def colour_of(locator, prop="color"):
|
|
return colour_name(locator.evaluate(f"el => getComputedStyle(el).{prop}"))
|
|
|
|
|
|
def test_log_lines_show_their_type(page, push):
|
|
open_tab(page, "Run")
|
|
lines = [("info", "Opening the portal", "blue"), ("success", "Logged in.", "green"),
|
|
("warn", "Slow response", "amber"), ("error", "Login failed", "red"),
|
|
("error", "<b>raw bot line</b>", "red")]
|
|
for kind, msg, _ in lines:
|
|
push("log", {"msg": msg, "type": kind})
|
|
log = page.get_by_role("log")
|
|
for _, msg, colour in lines:
|
|
line = log.get_by_text(msg, exact=True)
|
|
expect(line).to_be_visible()
|
|
assert colour_of(line) == colour, msg
|
|
|
|
|
|
def test_report_search_filters_rows_and_shows_bucket_pill(page, push):
|
|
keys = [c["key"] for c in page.evaluate("REPORT_COLUMNS")]
|
|
assert "status" in keys, "the report needs a status column for the bucket pill"
|
|
cases = [("alpha", "Posted", "success", "green"), ("bravo", "Needs review", "attention", "amber"),
|
|
("charlie", "Error", "failed", "red")]
|
|
rows = [{**{k: f"{k}-{name}" for k in keys}, "status": status, "bucket": bucket}
|
|
for name, status, bucket, _ in cases]
|
|
open_tab(page, "Report")
|
|
push("report_data", {"rows": rows})
|
|
body = page.get_by_role("table").locator("tbody").get_by_role("row")
|
|
expect(body).to_have_count(3)
|
|
search = page.get_by_role("searchbox", name="Search report")
|
|
for name, status, _, colour in cases:
|
|
search.fill(name)
|
|
expect(body).to_have_count(1)
|
|
pill = body.get_by_text(status, exact=True)
|
|
expect(pill).to_be_visible()
|
|
assert colour_of(pill, "backgroundColor") == colour, status
|
|
search.fill("")
|
|
expect(body).to_have_count(3)
|
|
|
|
|
|
def is_dark(page):
|
|
"""Dark theme = light text on the page."""
|
|
rgb = re.findall(r"\d+", page.evaluate("getComputedStyle(document.body).color"))[:3]
|
|
return sum(int(x) for x in rgb) / 3 > 128
|
|
|
|
|
|
def test_dark_toggle_flips_theme(page, request):
|
|
before = is_dark(page)
|
|
assert before == (request.node.callspec.params["page"] == "dark") # apply_dark took effect
|
|
toggle = page.get_by_role("button", name="Dark mode", exact=True)
|
|
expect(toggle).to_have_attribute("aria-pressed", str(before).lower())
|
|
toggle.click()
|
|
assert is_dark(page) != before
|
|
expect(toggle).to_have_attribute("aria-pressed", str(not before).lower())
|
|
assert ["set_dark", not before] in page.evaluate("window.__calls") # host is asked to remember it
|
|
|
|
|
|
def expect_sample_dashboard(page):
|
|
legend = page.get_by_role("list", name="Batch status")
|
|
expect(legend).to_contain_text("·")
|
|
n = sum(int(c) for c in re.findall(r"· (\d+)", legend.inner_text()))
|
|
assert n > 0
|
|
kpi(page, "Total batches", n)
|
|
|
|
|
|
@pytest.mark.usefixtures("sample_data")
|
|
def test_no_bot_page_runs_on_sample_data(page):
|
|
"""No bot yet: the page answers its own data actions, so it works on day one."""
|
|
expect_sample_dashboard(page)
|
|
|
|
open_tab(page, "Report")
|
|
rows = page.get_by_role("table").locator("tbody").get_by_role("row")
|
|
expect(rows.first).not_to_have_text("No rows yet")
|
|
|
|
run = page.get_by_role("button", name="▶ Run", exact=True)
|
|
run.click()
|
|
expect(run).to_be_disabled()
|
|
expect(page.get_by_role("list", name="Steps").get_by_role("listitem").last).to_have_text(
|
|
re.compile(r"^\s*✓"), timeout=10000)
|
|
expect(run).to_be_enabled()
|
|
|
|
|
|
@pytest.mark.usefixtures("sample_data")
|
|
def test_plain_browser_first_open_shows_sample_data(browser, page_url):
|
|
pg = browser.new_page()
|
|
pg.goto(page_url)
|
|
expect_sample_dashboard(pg)
|
|
pg.close()
|