No-AI skill tracer: SKILL.md, theme presets, sample data, Seam 1 page check
- rpa-dashboard-no-ai/SKILL.md: trigger description and the workflow in spec order (scan, report/keep-or-restyle, colours, stack, sections, build, wire, verify). - templates/themes.md: Tebra Sky and Superbill Ember, light and dark blocks. - templates/palette.py: derives a custom palette from one brand hex and checks text contrast in light and dark. - dashboard.html: built-in sample data so a page with no bot works on first open; cleared batches in the donut; component tokens so Ember is a token-only swap; accessible names for KPI cards, nav, steps, log, search and the dark toggle. - bridge.py: placeholders inside string literals, so it passes the lint hook unfilled. - tests/page_check: Seam 1 harness (fake Qt bridge, light/dark/narrow). - tests/evals: Seam 2 runner and fixture a-empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49f8f49b65
commit
7f2ddecffb
13 files changed
+865
-57
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# Tests
|
||||
|
||||
Run everything from the repo root:
|
||||
|
||||
```
|
||||
uv run --with pytest --with playwright pytest tests
|
||||
```
|
||||
|
||||
- `test_*.py`: assert-based self-checks for template logic (lint of unfilled Python
|
||||
templates, the custom palette, presets in step with the template).
|
||||
- `page_check/`: **Seam 1.** Loads a dashboard page in headless Chromium with no
|
||||
backend, pushes contract envelopes through `window.__pyMsg`, and asserts on what
|
||||
is visible. Every case runs in light, dark and narrow (800px) mode.
|
||||
- By default it checks the no-AI template, filled with test values. To check a
|
||||
generated page instead, set `PAGE_CHECK_HTML=path/to/dashboard.html`.
|
||||
- The page sees a fake Qt bridge. Every action the page sends is recorded in
|
||||
`window.__calls` as `[slot, ...args]`.
|
||||
- To add a case, write a `test_*.py` file in `page_check/` that uses the `page`
|
||||
and `push(event, data)` fixtures from `conftest.py`.
|
||||
- `evals/`: **Seam 2.** Runs the skills on a fixture project with `claude -p`,
|
||||
then grades the reply and runs the page check on the page it built:
|
||||
|
||||
```
|
||||
uv run --with pytest --with playwright python tests/evals/run_eval.py a-empty
|
||||
```
|
||||
|
||||
Results go to `evals/results/<fixture>.json`. To add a fixture, add an entry to
|
||||
`FIXTURES` in `run_eval.py`.
|
||||
- `fixtures/<letter>-<name>/`: small test projects for the evals.
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Seam 2 skill eval: run the skills on a fixture project with `claude -p`, then grade.
|
||||
|
||||
Usage (from the repo root):
|
||||
uv run --with pytest --with playwright python tests/evals/run_eval.py a-empty
|
||||
|
||||
Copies tests/fixtures/<fixture> to a temp folder, installs every rpa-dashboard-*
|
||||
skill there as a project skill, and runs `claude -p` with the user's answers in the
|
||||
prompt (`-p` can't answer questions). It then grades the reply, runs the Seam 1 page
|
||||
check on the page it built, and writes tests/evals/results/<fixture>.json.
|
||||
Exit code 0 means every check passed.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REPLY_FORMAT = """
|
||||
When you're done, end your reply with exactly these two lines:
|
||||
FRONTEND: <what your scan found, or none>
|
||||
QUESTIONS: <each question the skill told you to ask, in order, separated by " | ">"""
|
||||
|
||||
# Per fixture: the request, what the reply must (not) mention, and checks on the output.
|
||||
FIXTURES = {
|
||||
"a-empty": {
|
||||
"prompt": "Build me an RPA dashboard for this project. I can't answer questions while you "
|
||||
"work, so here are my answers to anything the skill asks. Colours: custom, my "
|
||||
"brand colour is #7a3cff. Desktop or web: desktop. Optional sections: none.",
|
||||
"frontend": r"^\s*(none|no\b)",
|
||||
"asks": [r"colou?r", r"desktop|web", r"section"],
|
||||
"never_asks": [r"\bkeep\b|restyle"],
|
||||
"brand": "#7a3cff",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main(fixture):
|
||||
cfg = FIXTURES[fixture]
|
||||
work = Path(tempfile.mkdtemp(prefix=f"rpa-eval-{fixture}-"))
|
||||
project = work / "project"
|
||||
shutil.copytree(ROOT / "tests" / "fixtures" / fixture, project)
|
||||
for skill in ROOT.glob("rpa-dashboard-*"):
|
||||
shutil.copytree(skill, project / ".claude" / "skills" / skill.name)
|
||||
|
||||
run = subprocess.run(
|
||||
[shutil.which("claude") or "claude", "-p", cfg["prompt"] + REPLY_FORMAT, "--output-format", "json",
|
||||
"--permission-mode", "acceptEdits", "--allowedTools", "Skill", "Read", "Write", "Edit",
|
||||
"Glob", "Grep", "Bash(python *)", "PowerShell(python *)"],
|
||||
cwd=project, capture_output=True, text=True, encoding="utf-8", timeout=1800)
|
||||
reply = json.loads(run.stdout).get("result", "") if run.stdout.strip().startswith("{") else run.stdout
|
||||
|
||||
def line(key):
|
||||
m = re.search(rf"^\W*{key}\W*:(.*)$", reply, re.M | re.I)
|
||||
return m[1] if m else ""
|
||||
|
||||
checks = {"frontend reported correctly": bool(re.search(cfg["frontend"], line("FRONTEND"), re.I))}
|
||||
for pat in cfg["asks"]:
|
||||
checks[f"asks /{pat}/"] = bool(re.search(pat, line("QUESTIONS"), re.I))
|
||||
for pat in cfg["never_asks"]:
|
||||
checks[f"never asks /{pat}/"] = not re.search(pat, line("QUESTIONS"), re.I)
|
||||
|
||||
pages = [p for p in project.rglob("*.html") if ".claude" not in p.parts]
|
||||
checks["exactly one page built"] = len(pages) == 1
|
||||
results = ROOT / "tests" / "evals" / "results"
|
||||
results.mkdir(exist_ok=True)
|
||||
if pages:
|
||||
html = pages[0].read_text(encoding="utf-8")
|
||||
shutil.copy(pages[0], results / f"{fixture}.dashboard.html")
|
||||
checks["no placeholders left"] = "{{" not in html
|
||||
if cfg.get("brand"):
|
||||
spec = importlib.util.spec_from_file_location("palette", ROOT / "rpa-dashboard-no-ai/templates/palette.py")
|
||||
palette = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(palette)
|
||||
t = palette.derive(cfg["brand"])
|
||||
checks["custom palette applied"] = all(f"--accent:{t[m]['--accent']}" in html for m in t)
|
||||
pc = subprocess.run([sys.executable, "-m", "pytest", str(ROOT / "tests" / "page_check"), "-q"],
|
||||
env={**os.environ, "PAGE_CHECK_HTML": str(pages[0])}, capture_output=True, text=True)
|
||||
checks["page check passes"] = pc.returncode == 0
|
||||
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
|
||||
else:
|
||||
page_check = ["no page"]
|
||||
|
||||
out = {"fixture": fixture, "passed": all(checks.values()), "checks": checks, "page_check": page_check,
|
||||
"files": sorted(str(p.relative_to(project)) for p in project.rglob("*") if p.is_file() and ".claude" not in p.parts),
|
||||
"reply": reply}
|
||||
(results / f"{fixture}.json").write_text(json.dumps(out, indent=2), encoding="utf-8")
|
||||
for name, ok in checks.items():
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}")
|
||||
print("page check:", *page_check)
|
||||
print("work dir:", project)
|
||||
return 0 if out["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1]))
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# Fixture (a): empty project
|
||||
|
||||
No frontend and no bot. The no-AI skill should report no frontend, ask for colours,
|
||||
the stack and optional sections, and produce a page that runs on sample data.
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Seam 1 page check: load a dashboard page with no backend, push contract envelopes
|
||||
through window.__pyMsg, and assert on what is visible.
|
||||
|
||||
Page under test: set PAGE_CHECK_HTML to a generated dashboard.html. Without it, the
|
||||
no-AI template is filled with TEST_FILL. Every test runs in light, dark and narrow mode.
|
||||
The page sees a fake Qt bridge: each slot call lands in window.__calls as [slot, ...args].
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TEMPLATE = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
|
||||
TEST_FILL = {
|
||||
"APP_NAME": "Test Bot",
|
||||
"APP_SUBTITLE": "Page check",
|
||||
"LOGO_SRC": "",
|
||||
"API_BASE": "",
|
||||
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
|
||||
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
|
||||
}
|
||||
FAKE_BRIDGE = """
|
||||
window.__calls = [];
|
||||
window.qt = {webChannelTransport: {}};
|
||||
window.QWebChannel = function (_transport, ready) {
|
||||
const pyBridge = new Proxy({}, {get: (_o, slot) => (...args) => { window.__calls.push([slot, ...args]); }});
|
||||
ready({objects: {pyBridge}});
|
||||
};
|
||||
"""
|
||||
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def page_url(tmp_path_factory):
|
||||
if os.environ.get("PAGE_CHECK_HTML"):
|
||||
return Path(os.environ["PAGE_CHECK_HTML"]).resolve().as_uri()
|
||||
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], TEMPLATE.read_text(encoding="utf-8"))
|
||||
out = tmp_path_factory.mktemp("page") / "dashboard.html"
|
||||
out.write_text(html, encoding="utf-8")
|
||||
return out.as_uri()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser():
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
yield b
|
||||
b.close()
|
||||
|
||||
|
||||
@pytest.fixture(params=list(MODES))
|
||||
def page(request, browser, page_url):
|
||||
width, height, dark = MODES[request.param]
|
||||
ctx = browser.new_context(viewport={"width": width, "height": height})
|
||||
ctx.add_init_script(FAKE_BRIDGE)
|
||||
pg = ctx.new_page()
|
||||
pg.set_default_timeout(5000)
|
||||
errors = []
|
||||
pg.on("pageerror", lambda e: errors.append(e))
|
||||
pg.goto(page_url)
|
||||
assert not errors, f"page script errors: {errors}"
|
||||
if dark:
|
||||
push_to(pg, "apply_dark", {"on": True})
|
||||
yield pg
|
||||
ctx.close()
|
||||
|
||||
|
||||
def push_to(pg, event, data):
|
||||
pg.evaluate("([event, data]) => window.__pyMsg(JSON.stringify({event, data}))", [event, data])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def push(page):
|
||||
"""push(event, data): send one contract envelope to the page, as the bot would."""
|
||||
return lambda event, data: push_to(page, event, data)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""No-AI contract cases: events in, visible result out (see templates/contract.md)."""
|
||||
import re
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Custom palette: one brand hex in, light + dark accent tokens out, readable in both modes."""
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PALETTE = Path(__file__).resolve().parents[1] / "rpa-dashboard-no-ai" / "templates" / "palette.py"
|
||||
spec = importlib.util.spec_from_file_location("palette", PALETTE)
|
||||
palette = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(palette)
|
||||
|
||||
# Fixed Tebra Sky base colours the derived accents sit on (from dashboard.html).
|
||||
LIGHT_BG, DARK_BG = "#eaf6fb", "#182a38"
|
||||
LIGHT_INK, DARK_INK = "#1c2b33", "#e6eef4"
|
||||
LOG_ERROR = "#f68c82" # dimmest log text colour
|
||||
|
||||
|
||||
def contrast(a, b):
|
||||
def lum(h):
|
||||
c = [int(h[i:i + 2], 16) / 255 for i in (1, 3, 5)]
|
||||
c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c]
|
||||
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
hi, lo = sorted([lum(a), lum(b)], reverse=True)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def test_derived_palette_is_readable_in_light_and_dark():
|
||||
for brand in ["#3d9bd0", "#ffd400", "#0b1f4d", "#7a3cff", "#e4002b", "#00a651", "#ffffff", "#000000"]:
|
||||
t = palette.derive(brand)
|
||||
for mode, bg, ink in [("light", LIGHT_BG, LIGHT_INK), ("dark", DARK_BG, DARK_INK)]:
|
||||
m = t[mode]
|
||||
assert contrast("#ffffff", m["--accent"]) >= 3, (brand, mode, "button text")
|
||||
assert contrast("#ffffff", m["--accent-2"]) >= 3, (brand, mode, "button text, light end")
|
||||
assert contrast(m["--accent"], bg) >= 3, (brand, mode, "accent text")
|
||||
assert contrast(ink, m["--accent-soft"]) >= 4.5, (brand, mode, "secondary button text")
|
||||
assert contrast(LOG_ERROR, m["--accent-dark"]) >= 4.5, (brand, mode, "log text")
|
||||
|
||||
|
||||
def test_brand_kept_when_it_already_passes():
|
||||
assert palette.derive("#1f6fb2")["light"]["--accent"] == "#1f6fb2"
|
||||
|
||||
|
||||
def test_cli_prints_both_token_blocks_and_contrast_table():
|
||||
out = subprocess.run([sys.executable, str(PALETTE), "#7a3cff"], capture_output=True, text=True, check=True).stdout
|
||||
assert "body{" in out and "body.dark{" in out and "PASS" in out and "FAIL" not in out
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Python templates must stay valid Python before their placeholders are filled."""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HOOK_RULES = "E9,F63,F7,F82,F811" # same rule set as the PostToolUse lint hook
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py"])
|
||||
def test_template_passes_lint_hook_unfilled(template):
|
||||
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
|
||||
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
|
||||
capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stdout
|
||||
|
||||
|
||||
def test_tebra_sky_preset_is_the_template_default():
|
||||
templates = ROOT / "rpa-dashboard-no-ai" / "templates"
|
||||
themes = (templates / "themes.md").read_text(encoding="utf-8")
|
||||
sky = themes.split("## 1. Tebra Sky")[1].split("```css\n")[1].split("```")[0]
|
||||
assert sky in (templates / "dashboard.html").read_text(encoding="utf-8")
|
||||
Reference in new issue
Block a user