Merge feature/workbench-w2-kanban-forecast into unify/skills-20261112
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9d6b3d26bf
18 files changed
+1375
-76
No files matched your search
@@ -130,7 +130,7 @@ PROBE = """(async () => {@HELPERS@
|
||||
order: all("#main .agent-order li").map((item) => item.dataset.name),
|
||||
taskFilters: all('#main [aria-label="Task filters"] button').map((button) => [button.dataset.filter, button.getAttribute("aria-pressed"), text(button.querySelector(".count"))]),
|
||||
taskRows: all("#main .task-row").map((row) => [row.dataset.task, row.classList.contains("open-work")]),
|
||||
columns: all("#main .task-column").map((column) => [column.dataset.column, all(".task-card", column).map((card) => card.dataset.task)]),
|
||||
columns: all("#main .task-column").map((column) => [column.dataset.column, all(`#main .kanban-cell[data-column="${column.dataset.column}"] .task-card`).map((card) => card.dataset.task)]),
|
||||
matrix: [all("#main table.matrix thead th").length, all("#main table.matrix tbody tr").length, all("#main table.matrix td .letter").map(text).join("")],
|
||||
flows: all("#main .flow").map((flow) => flow.dataset.flow),
|
||||
flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side),
|
||||
@@ -1839,7 +1839,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(listed["taskFilters"], [["all", "true", "6"], ["open", "false", "4"], ["work", "false", "4"], ["operator", "false", "2"], ["grant", "false", "1"], ["ended", "false", "2"]])
|
||||
self.assertEqual(listed["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order])
|
||||
self.assertEqual(listed["flows"], ["file", "file"])
|
||||
self.assertEqual(by_hash["#tasks/board"]["columns"], [["queued", ["queue/gate-3"]], ["running", ["work/feature", "work/stage"]], ["blocked", ["work/docs"]], ["done", ["work/old", "operator/push"]], ["stopped", []]])
|
||||
self.assertEqual(by_hash["#tasks/board"]["columns"], [["queued", ["work/docs", "queue/gate-3"]], ["running", ["work/feature", "work/stage"]], ["done", []]])
|
||||
self.assertEqual(by_hash["#tasks/rasci"]["matrix"], [6, 6, "CIRARASRARAR"])
|
||||
self.assertEqual([probe["taskFilters"][3][1] for probe in probes if probe["hash"] == "#tasks/operator"], ["true"] * len(WIDTHS))
|
||||
extra = data["results"][len(probes) :]
|
||||
@@ -2423,7 +2423,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual((fit["hash"], fit["bar"], fit["choices"], fit["tall"], fit["wide"]), (f"#tasks/{name}", CHOICE_CHANNELS, 0, False, False), fit)
|
||||
self.assertEqual((fit["rows"] == 1, fit["layout"]), (width != 390, ["toolbar", "chips", "body"]), fit)
|
||||
if name == "board":
|
||||
self.assertEqual(fit["columns"], ["queued", "running", "blocked", "done", "stopped"])
|
||||
self.assertEqual(fit["columns"], ["queued", "running", "done"])
|
||||
hub = self.httpd.hub
|
||||
if not signs:
|
||||
self.assertEqual((self.scripts.calls, hub.messages.listed()), ([], []))
|
||||
@@ -2664,11 +2664,11 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertAlmostEqual(begin, start, delta=0.01)
|
||||
self.assertAlmostEqual(end, forecast if forecast is not None else probe["points"][-1][0], delta=0.01)
|
||||
if forecast is None:
|
||||
self.assertEqual((probe["note"], probe["marker"]), ([None, "No forecast: the trend never reaches 0 remaining, so done never meets scope"], None))
|
||||
self.assertEqual((probe["note"], probe["marker"]), ([None, "∞"], None))
|
||||
return
|
||||
self.assertAlmostEqual(probe["note"][0], forecast, delta=0.01)
|
||||
self.assertAlmostEqual(probe["marker"], forecast, delta=0.01)
|
||||
self.assertTrue(probe["note"][1].startswith("Forecast: "), probe["note"])
|
||||
self.assertNotEqual(probe["note"][1], "∞", probe["note"])
|
||||
|
||||
def check_trend_eta_3d(self, browser: str, driver: str, binary: str) -> None:
|
||||
self.trend_work(time.time())
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path[:0] = [str(HERE.parent), str(HERE)]
|
||||
|
||||
import test_orchestrator_e2e as base
|
||||
from hub_case import HubCase
|
||||
from hub_support import iso
|
||||
from kvb_orchestrator import skill_scripts, tasking
|
||||
|
||||
OUT = Path(os.environ.get("E2E_OUT", "/tmp/workbench-w2-shots"))
|
||||
DAY = 86400
|
||||
WHY = "rasci: work/a: 1 key result(s) open; mark them met or set stopped"
|
||||
STAGED = [
|
||||
["work/c", "set work/c --status running"],
|
||||
["work/c", "set work/c --prio 2"],
|
||||
["work/c", "set work/c --after work/a"],
|
||||
["work/b", "set work/b --drop-after work/a"],
|
||||
["work/d", "set work/d --prio 4"],
|
||||
["work/stale", "set work/stale --status stopped"],
|
||||
]
|
||||
EMPTY = {"queued": [], "running": [], "done": []}
|
||||
BOARD_HELPERS = """
|
||||
let asked = 0;
|
||||
const get = navigator.credentials.get.bind(navigator.credentials);
|
||||
navigator.credentials.get = (options) => {
|
||||
asked += 1;
|
||||
return @SIGNS@ ? get(options) : Promise.reject(new DOMException("The operator canceled.", "NotAllowedError"));
|
||||
};
|
||||
const posted = [];
|
||||
const send = window.fetch.bind(window);
|
||||
window.fetch = (path, init) => {
|
||||
if (["/api/sign", "/api/tasks"].includes(String(path))) {
|
||||
posted.push([String(path), JSON.parse(init.body).items.map((item) => [item.id, item.argv.join(" ")])]);
|
||||
}
|
||||
return send(path, init);
|
||||
};
|
||||
const fresh = async (shown, prefix) => until(() => all("#toasts .toast").filter((box) => !shown.has(box)).map(text).find((entry) => entry.startsWith(prefix)), 20000);
|
||||
const fits = () => ({tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1});
|
||||
const card = (task) => all("#main .kanban-card").find((node) => node.dataset.task === task) || null;
|
||||
const cell = (lane, column) => document.querySelector(`#main .kanban-cell[data-lane="${lane}"][data-column="${column}"]`);
|
||||
const drag = async (source, target) => {
|
||||
const data = new DataTransfer();
|
||||
const fire = (node, type) => node.dispatchEvent(new DragEvent(type, {bubbles: true, cancelable: true, dataTransfer: data}));
|
||||
fire(source, "dragstart");
|
||||
fire(target, "dragenter");
|
||||
fire(target, "dragover");
|
||||
fire(target, "drop");
|
||||
fire(source, "dragend");
|
||||
await sleep(400);
|
||||
};
|
||||
const read = () => ({
|
||||
columns: all("#main .kanban-head .task-column").map((node) => node.dataset.column),
|
||||
lanes: all("#main .kanban-lane").map((lane) => [lane.dataset.lane, lane.querySelector(".lane-toggle").getAttribute("aria-expanded"),
|
||||
Object.fromEntries(all(".kanban-cell", lane).map((box) => [box.dataset.column, all(".kanban-card", box).map((node) => node.dataset.task)]))]),
|
||||
labels: Object.fromEntries(all("#main .kanban-card").map((node) => [node.dataset.task, all(".pill[data-label]", node).map((pill) => pill.dataset.label)]).filter(([, found]) => found.length)),
|
||||
arrows: all("#main svg.kanban-lines .kanban-arrow").map((node) => [node.dataset.from, node.dataset.to, node.classList.contains("pending")]),
|
||||
solid: all("#main svg.kanban-lines .kanban-link").map((path) => getComputedStyle(path).strokeDasharray === "none"),
|
||||
queue: (() => {
|
||||
const line = document.querySelector("#main svg.kanban-lines .kanban-queue");
|
||||
return line ? [line.dataset.tasks, getComputedStyle(line).strokeDasharray !== "none"] : null;
|
||||
})(),
|
||||
pending: Object.fromEntries(all("#main .kanban-card.pending").map((node) => [node.dataset.task, node.dataset.pending])),
|
||||
apply: (() => {
|
||||
const button = document.getElementById("kanban-apply");
|
||||
return [button.disabled, button.getAttribute("aria-label"), text(button.querySelector(".count"))];
|
||||
})(),
|
||||
old: (() => {
|
||||
const button = document.getElementById("kanban-old");
|
||||
return [button.getAttribute("aria-pressed"), text(button.querySelector(".count"))];
|
||||
})(),
|
||||
stale: text(document.getElementById("kanban-stale")),
|
||||
why: Object.fromEntries(all("#main .kanban-card .task-why").map((node) => [node.closest(".kanban-card").dataset.task, text(node)])),
|
||||
partly: Object.fromEntries(all("#main .kanban-card .kanban-partly").map((node) => [node.closest(".kanban-card").dataset.task, node.dataset.partly])),
|
||||
listed: all("#kanban-staged li").map((item) => [item.dataset.task, text(item)]),
|
||||
unlabelled: all("#main .kanban-box svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length,
|
||||
...fits(),
|
||||
});
|
||||
"""
|
||||
BOARD = """(async () => {@HELPERS@@BOARD_HELPERS@
|
||||
if (!(await until(() => card("work/a") && document.querySelector("#main svg.kanban-lines .kanban-arrow"), 8000))) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const before = read();
|
||||
await drag(card("work/c"), cell("p2", "running"));
|
||||
await drag(card("work/a").querySelector(".kanban-handle"), card("work/c"));
|
||||
const arrow = all("#main svg.kanban-lines .kanban-arrow").find((node) => node.dataset.from === "work/a" && node.dataset.to === "work/b");
|
||||
arrow.dispatchEvent(new MouseEvent("click", {bubbles: true}));
|
||||
await sleep(400);
|
||||
const menu = card("work/d").querySelector("details.kanban-menu");
|
||||
menu.querySelector("summary").click();
|
||||
all("button", menu).find((button) => text(button) === "P4+").click();
|
||||
await sleep(400);
|
||||
card("work/stale").querySelector(".kanban-stop").click();
|
||||
await sleep(400);
|
||||
const staged = read();
|
||||
document.getElementById("kanban-old").click();
|
||||
await sleep(300);
|
||||
const older = read().lanes.find(([lane]) => lane === "unpinned");
|
||||
document.getElementById("kanban-old").click();
|
||||
await sleep(300);
|
||||
document.querySelector('#main .kanban-lane[data-lane="p4"] .lane-toggle').click();
|
||||
await sleep(300);
|
||||
const lane = document.querySelector('#main .kanban-lane[data-lane="p4"]');
|
||||
const folded = [lane.querySelector(".lane-toggle").getAttribute("aria-expanded"), lane.querySelector(".kanban-cells").hidden, read().listed];
|
||||
lane.querySelector(".lane-toggle").click();
|
||||
await sleep(300);
|
||||
card("work/b").querySelector(".kanban-title").click();
|
||||
await sleep(400);
|
||||
const popup = document.getElementById("popup");
|
||||
const popped = [popup.open, popup.getAttribute("aria-label")];
|
||||
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||||
await sleep(300);
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
document.getElementById("kanban-apply").click();
|
||||
const toast = await fresh(shown, "Apply 6 changes");
|
||||
await sleep(600);
|
||||
return {before, staged, older, folded, popped, toast, asked, posted, after: read()};
|
||||
})()"""
|
||||
FORECAST = """(async () => {@HELPERS@
|
||||
const ready = () => document.querySelector("#main svg.burn") && document.querySelector('#main select[aria-label="Trend"]');
|
||||
if (!(await until(ready, 8000))) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const choose = async (label, value) => {
|
||||
const select = document.querySelector(`#main select[aria-label="${label}"]`);
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
await sleep(600);
|
||||
};
|
||||
const read = () => ({
|
||||
points: Object.fromEntries(["scope", "done", "remaining"].map((name) => [name, all(`#main svg.burn circle.burn-point[data-series="${name}"]`).map((dot) => [Number(dot.dataset.t), Number(dot.dataset.value)])])),
|
||||
finishes: all("#main .burn-finish").map((node) => [node.dataset.finish, node.dataset.forecast === "" ? null : Number(node.dataset.forecast), text(node), node.getAttribute("aria-label"), getComputedStyle(node, "::before").backgroundColor]),
|
||||
lines: all("#main svg.burn path.burn-finish-line").map((path) => [path.dataset.finish, Number(path.dataset.at), getComputedStyle(path).stroke, path.getAttribute("d").match(/-?\\d+(?:\\.\\d+)?/g).map(Number)]),
|
||||
stale: (() => {
|
||||
const pill = document.querySelector("#main .burn-stale");
|
||||
return [pill.dataset.stale, text(pill), pill.getAttribute("aria-label")];
|
||||
})(),
|
||||
tall: document.documentElement.scrollHeight > window.innerHeight,
|
||||
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
});
|
||||
await choose("Trend", "regression");
|
||||
const whole = read();
|
||||
await choose("Scope", "agent:Docs");
|
||||
const docs = read();
|
||||
await choose("Scope", "all");
|
||||
return {whole, docs};
|
||||
})()"""
|
||||
|
||||
|
||||
def probe(template: str, signs: bool) -> str:
|
||||
helpers = BOARD_HELPERS.replace("@SIGNS@", "true" if signs else "false")
|
||||
return template.replace("@HELPERS@", base.HELPERS).replace("@BOARD_HELPERS@", helpers)
|
||||
|
||||
|
||||
def regression(points: list[list[float]]) -> tuple[float, float, float] | None:
|
||||
mean_t = sum(t for t, _ in points) / len(points)
|
||||
mean_y = sum(y for _, y in points) / len(points)
|
||||
spread = sum((t - mean_t) ** 2 for t, _ in points)
|
||||
return (mean_t, mean_y, sum((t - mean_t) * (y - mean_y) for t, y in points) / spread) if spread else None
|
||||
|
||||
|
||||
def finishes(points: dict) -> dict:
|
||||
"""Returns:
|
||||
The finish at fixed and at growing scope, computed here
|
||||
independently of plan.js from the drawn burn points.
|
||||
"""
|
||||
done, remaining = regression(points["done"]), regression(points["remaining"])
|
||||
last_t, last_scope = points["scope"][-1]
|
||||
t0, y0, slope = done
|
||||
fixed = last_t if points["remaining"][-1][1] <= 0 else max(last_t, t0 + (last_scope - y0) / slope) if slope > 0 else None
|
||||
r0, ry, rslope = remaining
|
||||
return {"fixed": fixed, "growing": r0 - ry / rslope if rslope < 0 else None}
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("CHROMIUM", "chromium")), "no Chromium")
|
||||
class TestWorkbenchKanban(HubCase):
|
||||
drive = base.TestOrchestratorE2E.drive
|
||||
authenticator = base.TestOrchestratorE2E.authenticator
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
fresh = iso(now - 3600)
|
||||
self.write_council({
|
||||
"rasci.json": {
|
||||
"work/a": {"title": "Alpha", "status": "queued", "worker": "Stage", "prio": 1, "key_results": [{"text": "shipped", "met": False}], "updated": fresh},
|
||||
"work/b": {"title": "Bravo", "status": "running", "worker": "Docs", "prio": 2, "start": iso(now - 3.9 * DAY), "after": ["work/a"], "updated": fresh},
|
||||
"work/blocked": {"title": "Blocked one", "status": "blocked", "worker": "Stage", "prio": 3, "updated": fresh},
|
||||
"work/p5": {"title": "Five", "status": "queued", "worker": "Stage", "prio": 5, "updated": fresh},
|
||||
"work/c": {"title": "Charlie", "status": "queued", "worker": "Stage", "estimates": {"Stage": {"value": 4, "complexity": 2}}, "updated": fresh},
|
||||
"work/d": {"title": "Delta", "status": "queued", "worker": "Skills", "estimates": {"Skills": {"value": 5, "complexity": 1}}, "updated": fresh},
|
||||
"work/stale": {"title": "Stale one", "status": "running", "worker": "Stage", "start": iso(now - 5 * DAY), "updated": iso(now - 4 * DAY)},
|
||||
"work/stopped": {"title": "Stopped one", "status": "stopped", "worker": "Stage", "start": iso(now - 3.7 * DAY), "end": iso(now - 3600)},
|
||||
"work/old": {"title": "Old one", "status": "done", "worker": "Stage", "start": iso(now - 4 * DAY), "end": iso(now - 2 * DAY)},
|
||||
"work/done": {"title": "Done one", "status": "done", "worker": "Stage", "start": iso(now - 3.8 * DAY), "end": iso(now - 7200)},
|
||||
"operator/push": {"title": "git push", "workdir": "/repo", "status": "queued"},
|
||||
},
|
||||
"queue.json": {"seq": 2, "entries": [
|
||||
{"id": "gate-1", "seq": 1, "kind": "gate", "task": "work/b", "name": "Docs", "hotfix": None, "status": "active", "enqueued": now - 300, "granted": now - 200, "cmd": "make test"},
|
||||
{"id": "gate-2", "seq": 2, "kind": "gate", "task": "work/c", "name": "Stage", "hotfix": None, "status": "waiting", "enqueued": now - 100, "granted": None, "cmd": "make test"},
|
||||
]},
|
||||
})
|
||||
signer = self.pin()
|
||||
items = [{"kind": "task", "id": "work/a", "argv": ["set", "work/a", "--status", "done"]}, {"kind": "task", "id": "work/a", "argv": ["set", "work/a", "--prio", "1"]}]
|
||||
status, _, body = self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))
|
||||
self.assertEqual((status, self.scripts.calls), (202, []), body)
|
||||
self.run_runner()
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["post", "--to", "Stage", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/a --prio 1"])])
|
||||
self.told = len(self.scripts.calls)
|
||||
|
||||
def steps(self, browser: str, signs: bool) -> list[dict]:
|
||||
local = f"http://localhost:{self.port}/"
|
||||
steps = []
|
||||
for width, height in base.WIDTHS:
|
||||
steps += [
|
||||
{"width": width, "height": height, "url": f"{local}?{width}#tasks/board", "wait": 1200, "probe": probe(BOARD, signs), "shot": str(OUT / f"{browser}-{width}-kanban.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-burn#tasks/burn", "wait": 1200, "probe": probe(FORECAST, signs), "shot": str(OUT / f"{browser}-{width}-forecast.png")},
|
||||
]
|
||||
return steps
|
||||
|
||||
def check_board(self, result: dict, signs: bool) -> None:
|
||||
self.assertNotIn("error", result, result)
|
||||
before, staged, after = result["before"], result["staged"], result["after"]
|
||||
self.assertEqual(before["columns"], ["queued", "running", "done"])
|
||||
self.assertEqual(before["lanes"], [
|
||||
["p1", "true", {**EMPTY, "queued": ["work/a"]}],
|
||||
["p2", "true", {**EMPTY, "running": ["work/b"]}],
|
||||
["p3", "true", {**EMPTY, "queued": ["work/blocked"]}],
|
||||
["p4", "true", {**EMPTY, "queued": ["work/p5"]}],
|
||||
["unpinned", "true", {**EMPTY, "queued": ["work/d", "work/c", "queue/gate-2"], "running": ["queue/gate-1", "work/stale"], "done": ["work/done", "work/stopped"]}],
|
||||
])
|
||||
self.assertEqual(before["labels"], {"work/blocked": ["blocked"], "work/stale": ["stale"], "work/stopped": ["stopped"]})
|
||||
self.assertEqual((before["arrows"], before["solid"], before["queue"]), ([["work/a", "work/b", False]], [True], ["work/b work/c", True]))
|
||||
self.assertEqual((before["old"], before["stale"], before["apply"], before["pending"]), (["false", "1"], "1", [True, "Apply 0 changes", "0"], {}))
|
||||
self.assertEqual((before["why"], before["partly"], before["listed"]), ({"work/a": WHY}, {"work/a": "1/2"}, []))
|
||||
self.assertEqual(staged["lanes"], [
|
||||
["p1", "true", {**EMPTY, "queued": ["work/a"]}],
|
||||
["p2", "true", {**EMPTY, "running": ["work/c", "work/b"]}],
|
||||
["p3", "true", {**EMPTY, "queued": ["work/blocked"]}],
|
||||
["p4", "true", {**EMPTY, "queued": ["work/d", "work/p5"]}],
|
||||
["unpinned", "true", {**EMPTY, "queued": ["queue/gate-2"], "running": ["queue/gate-1"], "done": ["work/done", "work/stale", "work/stopped"]}],
|
||||
])
|
||||
self.assertEqual(staged["arrows"], [["work/a", "work/c", True]])
|
||||
self.assertEqual(staged["pending"], {
|
||||
"work/c": "set work/c --status running\nset work/c --prio 2\nset work/c --after work/a",
|
||||
"work/b": "set work/b --drop-after work/a",
|
||||
"work/d": "set work/d --prio 4",
|
||||
"work/stale": "set work/stale --status stopped",
|
||||
})
|
||||
self.assertEqual((staged["apply"], staged["stale"], staged["labels"]["work/stale"]), ([False, "Apply 6 changes", "6"], "0", ["stopped"]))
|
||||
self.assertEqual(result["older"], ["unpinned", "true", {**EMPTY, "queued": ["queue/gate-2"], "running": ["queue/gate-1"], "done": ["work/done", "work/old", "work/stale", "work/stopped"]}])
|
||||
listed = [[task, f"rasci.py {argv}"] for task, argv in STAGED]
|
||||
self.assertEqual((staged["listed"], result["folded"], result["popped"]), (listed, ["false", True, listed], [True, "work/b"]))
|
||||
self.assertTrue(result["toast"].startswith(f"Apply 6 changes: {'handed to the runner' if signs else 'not signed'}"), result["toast"])
|
||||
signed = [["/api/sign", STAGED]]
|
||||
self.assertEqual((result["asked"], result["posted"]), (1, [*signed, ["/api/tasks", STAGED]] if signs else signed))
|
||||
self.assertEqual(after["apply"], [True, "Apply 0 changes", "0"] if signs else [False, "Apply 6 changes", "6"])
|
||||
for read in (before, staged, after):
|
||||
self.assertEqual((read["unlabelled"], read["tall"], read["wide"]), (0, False, False), read)
|
||||
|
||||
def check_forecast(self, result: dict) -> None:
|
||||
self.assertNotIn("error", result, result)
|
||||
whole, docs = result["whole"], result["docs"]
|
||||
expected = finishes(whole["points"])
|
||||
self.assertTrue(expected["fixed"] is not None and expected["growing"] is not None and expected["growing"] > expected["fixed"], expected)
|
||||
found = {kind: (at, words, label, colour) for kind, at, words, label, colour in whole["finishes"]}
|
||||
lines = {kind: (at, stroke, path) for kind, at, stroke, path in whole["lines"]}
|
||||
self.assertEqual((sorted(found), sorted(lines), len(whole["lines"])), (["fixed", "growing"], ["fixed", "growing"], 2))
|
||||
for kind, at in expected.items():
|
||||
with self.subTest(kind=kind):
|
||||
self.assertAlmostEqual(found[kind][0], at, delta=0.01)
|
||||
self.assertAlmostEqual(lines[kind][0], at, delta=0.01)
|
||||
self.assertNotEqual(found[kind][1], "∞")
|
||||
self.assertEqual(found[kind][3], lines[kind][1])
|
||||
self.assertNotEqual(lines["fixed"][1], lines["growing"][1])
|
||||
fixed_path, growing_path = lines["fixed"][2], lines["growing"][2]
|
||||
self.assertAlmostEqual(fixed_path[1], fixed_path[3], delta=0.05, msg=f"the fixed-scope finish runs level at the last scope: {fixed_path}")
|
||||
self.assertLess(growing_path[3], growing_path[1] - 1, f"the growing-scope finish rises along the scope trend: {growing_path}")
|
||||
self.assertEqual(whole["stale"][:2], ["1", "1"])
|
||||
self.assertEqual([entry[:3] for entry in docs["finishes"]], [["fixed", None, "∞"], ["growing", None, "∞"]])
|
||||
self.assertEqual((docs["lines"], docs["stale"][:2]), ([], ["0", "0"]))
|
||||
for read in (whole, docs):
|
||||
self.assertEqual((read["tall"], read["wide"]), (False, False), read)
|
||||
|
||||
def check(self, data: dict, browser: str, signs: bool) -> None:
|
||||
results = iter(data["results"])
|
||||
for width, _ in base.WIDTHS:
|
||||
with self.subTest(browser=browser, width=width):
|
||||
self.check_board(next(results), signs)
|
||||
self.check_forecast(next(results))
|
||||
self.assertEqual(self.scripts.calls[self.told:], [])
|
||||
if not signs:
|
||||
return
|
||||
self.run_runner()
|
||||
stage = f"{tasking.TOLD}: rasci.py set work/c --status running; rasci.py set work/c --prio 2; rasci.py set work/c --after work/a; rasci.py set work/stale --status stopped"
|
||||
batch = [
|
||||
(skill_scripts.RADIO, ["post", "--to", "Stage", "--session", "Operator", stage]),
|
||||
(skill_scripts.RADIO, ["post", "--to", "Docs", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/b --drop-after work/a"]),
|
||||
(skill_scripts.RADIO, ["post", "--to", "Skills", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/d --prio 4"]),
|
||||
]
|
||||
self.assertEqual(self.scripts.calls[self.told:], batch * len(base.WIDTHS))
|
||||
stored = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual((stored["work/c"]["status"], stored["work/c"]["prio"], stored["work/c"]["after"], "after" in stored["work/b"]), ("running", 2, ["work/a"], False))
|
||||
self.assertEqual((stored["work/d"]["prio"], stored["work/stale"]["status"], stored["work/a"]["status"]), (4, "stopped", "queued"))
|
||||
|
||||
def test_chromium_stages_drags_signs_one_batch_and_draws_both_forecasts_at_three_widths(self):
|
||||
data = self.drive(self.steps("chromium", True), self.authenticator())
|
||||
(OUT / "chromium-kanban.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], [])
|
||||
self.check(data, "chromium", True)
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||
def test_firefox_stages_drags_asks_the_passkey_and_draws_both_forecasts_storing_nothing_unsigned(self):
|
||||
data = self.drive(self.steps("firefox", False), None, "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||||
(OUT / "firefox-kanban.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], [])
|
||||
self.check(data, "firefox", False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import HUB, PLAN, iso, runner, signing
|
||||
from kvb_orchestrator import skill_scripts, tasking
|
||||
|
||||
KANBAN = HUB / "templates" / "kanban.js"
|
||||
DAY = 86400
|
||||
KANBAN_PROBE = """
|
||||
const K = require(process.argv[1]);
|
||||
const H = 3600, D = 86400, now = 100 * D;
|
||||
const row = (task, fields) => ({task, kind: "task", status: "queued", ended: false, start: null, end: null, prio: null, wsjf: null, after: [], movable: true, editable: true, ...fields});
|
||||
const rows = [
|
||||
row("work/p1", {prio: 1, status: "running", start: now - H}),
|
||||
row("work/p2", {prio: 2, status: "blocked"}),
|
||||
row("work/p3", {prio: 3, status: "blocked", start: now - H}),
|
||||
row("work/p4", {prio: 4}),
|
||||
row("work/p7", {prio: 7, status: "stopped", ended: true, end: now - H}),
|
||||
row("work/low", {wsjf: {score: 1.5}}),
|
||||
row("work/high", {wsjf: {score: 4}}),
|
||||
row("work/none", {}),
|
||||
row("work/also", {wsjf: {score: 4}}),
|
||||
row("work/old", {status: "done", ended: true, end: now - D - 1}),
|
||||
row("work/fresh", {status: "done", ended: true, end: now - D + 1}),
|
||||
row("operator/push", {kind: "command"}),
|
||||
row("queue/gate-1", {kind: "queue", status: "running"}),
|
||||
];
|
||||
const hub = (task) => rows.find((entry) => entry.task === task);
|
||||
const place = (old) => {
|
||||
const found = K.board(rows, {now, old});
|
||||
return {hidden: found.hidden, lanes: found.lanes.map((lane) => [lane.id, lane.label, lane.count, Object.fromEntries(Object.entries(lane.cells).map(([column, cards]) => [column, cards.map((entry) => entry.task)]))])};
|
||||
};
|
||||
const shown = (staged) => staged.map((change) => [change.task, change.argv.join(" ")]);
|
||||
let staged = [];
|
||||
staged = K.stage(staged, hub("work/none"), {column: "running"}, rows);
|
||||
staged = K.stage(staged, hub("work/none"), {lane: "p2"}, rows);
|
||||
staged = K.stage(staged, hub("work/low"), {lane: "p4"}, K.pending(rows, staged));
|
||||
staged = K.stage(staged, hub("work/high"), {lane: "p4"}, K.pending(rows, staged));
|
||||
staged = K.stage(staged, hub("work/p1"), {lane: "unpinned"}, rows);
|
||||
staged = K.stage(staged, hub("work/none"), {after: "work/p1"}, rows);
|
||||
const first = shown(staged);
|
||||
staged = K.stage(staged, hub("work/none"), {column: "done"}, rows);
|
||||
const replaced = shown(staged);
|
||||
staged = K.stage(staged, hub("work/none"), {lane: "unpinned"}, rows);
|
||||
staged = K.stage(staged, hub("work/none"), {drop: "work/p1"}, rows);
|
||||
const back = shown(staged);
|
||||
const applied = Object.fromEntries(K.pending(rows, staged).filter((entry) => entry.pending).map((entry) => [entry.task, [entry.status, entry.ended, entry.prio, entry.after, entry.pending.length]]));
|
||||
const dep = row("work/dep", {after: ["work/p1", "work/p2"]});
|
||||
const linked = [row("a", {}), row("b", {after: ["a"]}), row("c", {after: ["a", "b", "gone", "c"]})];
|
||||
const job = (id, task, batch, status, created_ts, output) => ({id, kind: "task", task, batch, status, created_ts, output: output || "", command: id});
|
||||
const jobs = [
|
||||
job("pin", "work/x", "b2", "done", 21),
|
||||
job("old", "work/x", "b1", "failed", 10, "rasci: old"),
|
||||
job("status", "work/x", "b2", "failed", 20, "x\\nrasci: work/x: 1 key result(s) open"),
|
||||
job("lone", "work/y", undefined, "refused", 5, "refused: expired"),
|
||||
job("cmd", "work/z", "b3", "failed", 30),
|
||||
job("fixed", "work/w", "b4", "failed", 1),
|
||||
job("later", "work/w", "b5", "done", 2),
|
||||
{id: "run", task: "work/v", status: "failed", created_ts: 3, output: ""},
|
||||
];
|
||||
const reasons = (found) => found && {...found, failed: found.failed.map((entry) => entry.id)};
|
||||
console.log(JSON.stringify({
|
||||
columns: K.COLUMNS, lanes: K.LANES, freeFrom: K.FREE_FROM,
|
||||
sorted: [["running", null], ["blocked", null], ["blocked", 5], ["queued", null], ["done", 1], ["stopped", 1], ["awaiting", null]].map(([status, start]) => K.columnOf(row("x", {status, start}))),
|
||||
labels: rows.slice(0, 5).map(K.labelOf),
|
||||
lanesOf: [null, 0, -1, 1, 2, 3, 4, 9].map(K.laneOf),
|
||||
belowOne: K.board([row("work/zero", {prio: 0}), row("work/minus", {prio: -3})], {now, old: false}).lanes.map((lane) => [lane.id, lane.count]),
|
||||
why: Object.fromEntries(["work/x", "work/y", "work/z", "work/w", "work/v", "work/none"].map((task) => [task, reasons(K.why(jobs, task))])),
|
||||
onBoard: rows.filter(K.onBoard).length,
|
||||
hidden: place(false), shown: place(true),
|
||||
pins: [K.freePin(rows, "work/none"), K.freePin([...rows, row("work/five", {prio: 5})], "work/none"), K.freePin(rows, "work/p4")],
|
||||
first, replaced, back, applied,
|
||||
untouched: K.pending(rows, staged).filter((entry, index) => entry === rows[index]).length,
|
||||
noops: [K.move(hub("work/p1"), {column: "running"}, rows), K.move(hub("work/p3"), {column: "running"}, rows), K.move(hub("work/p2"), {lane: "p2"}, rows), K.move(hub("work/p7"), {lane: "p4"}, rows),
|
||||
K.move(hub("work/p3"), {after: "work/p3"}, rows), K.move(dep, {after: "work/p1"}, rows), K.move(dep, {drop: "work/p3"}, rows), K.move(hub("work/p7"), {status: "stopped"}, rows)],
|
||||
moves: [K.move(hub("work/none"), {status: "stopped"}, rows), K.move(dep, {drop: "work/p1"}, rows), K.move(hub("work/fresh"), {column: "running"}, rows)],
|
||||
pendingAfter: K.pending([dep], [{task: "work/dep", argv: ["set", "work/dep", "--after", "work/p3"]}, {task: "work/dep", argv: ["set", "work/dep", "--drop-after", "work/p1"]}])[0].after,
|
||||
lines: K.lines(linked, ["gone", "c", "a"]),
|
||||
}));
|
||||
"""
|
||||
FORECAST_PROBE = """
|
||||
const P = require(process.argv[1]);
|
||||
const D = 86400, now = 100 * D;
|
||||
const points = (scope, done) => scope.map((value, index) => ({t: index * D, scope: value, done: done[index], remaining: value - done[index], unestimated: 0}));
|
||||
const days = (t) => (t === null ? null : t / D);
|
||||
const finish = (found, mode) => {
|
||||
const reached = P.finishes(found, P.trend(found, {mode, days: 7}));
|
||||
return [days(reached.fixed), days(reached.growing)];
|
||||
};
|
||||
const row = (task, fields) => ({task, kind: "task", status: "queued", ended: false, start: null, end: null, points: null, worker: "-", roles: {}, updated: null, ...fields});
|
||||
const rows = [
|
||||
row("work/a", {status: "running", start: now - 5 * D, updated: now - 4 * D}),
|
||||
row("work/b", {status: "running", start: now - 2 * D, updated: now - D}),
|
||||
row("work/c", {status: "done", ended: true, start: now - 6 * D, end: now - 5 * D, updated: now - 5 * D}),
|
||||
row("operator/push", {kind: "command", updated: now - 9 * D}),
|
||||
row("queue/x", {kind: "queue", status: "awaiting"}),
|
||||
];
|
||||
const scope = (choice) => {
|
||||
const found = P.scoped(rows, choice, {filtered: rows, since: now - D, until: now, now, holders: {}});
|
||||
return {rows: found.rows.map((entry) => entry.task), stale: found.stale.map((entry) => entry.task), from: found.from / D, to: found.to / D};
|
||||
};
|
||||
console.log(JSON.stringify({
|
||||
fixedScope: finish(points([4, 4, 4], [0, 1, 2]), "regression"),
|
||||
growing: finish(points([4, 5, 6], [0, 2, 4]), "regression"),
|
||||
growingNever: finish(points([4, 5, 6], [0, 1, 2]), "regression"),
|
||||
never: finish(points([4, 4, 4], [1, 1, 1]), "regression"),
|
||||
finished: finish(points([2, 3, 3], [0, 2, 3]), "regression"),
|
||||
velocity: finish(points([4, 5, 6], [0, 2, 4]), "velocity"),
|
||||
noTrend: P.finishes(points([1], [0]), null),
|
||||
beyond: P.finishes([{t: 0, scope: 1, done: 0, remaining: 1}, {t: 1, scope: 1, done: 1e-13, remaining: 1 - 1e-13}], P.trend([{t: 0, scope: 1, done: 0, remaining: 1}, {t: 1, scope: 1, done: 1e-13, remaining: 1 - 1e-13}], {mode: "regression", days: 7})),
|
||||
staleDays: P.STALE_DAYS,
|
||||
stale: [row("t", {updated: now - 3 * D}), row("t", {updated: now - 3 * D + 1}), row("t", {updated: now - 10 * D, ended: true}), row("t", {}), row("t", {updated: undefined})].map((entry) => P.stale(entry, now)),
|
||||
all: scope("all"), filter: scope("filter"),
|
||||
burn: P.burn(P.scoped(rows, "all", {filtered: rows, since: now - D, until: now, now, holders: {}}).rows, {estimation: "none", from: now - 6 * D, to: now}).points.pop(),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def change(argv: list, task: str) -> dict:
|
||||
return {"kind": "task", "id": task, "argv": argv}
|
||||
|
||||
|
||||
class TestKanbanModule(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
done = subprocess.run(["node", "-e", KANBAN_PROBE, str(KANBAN)], capture_output=True, text=True, timeout=60, check=True)
|
||||
cls.data = json.loads(done.stdout)
|
||||
|
||||
def test_the_board_has_waiting_running_and_done_and_blocked_and_stopped_are_labels(self):
|
||||
self.assertEqual(self.data["columns"], [["queued", "Waiting"], ["running", "Running"], ["done", "Done"]])
|
||||
self.assertEqual(self.data["sorted"], ["running", "queued", "running", "queued", "done", "done", "queued"])
|
||||
self.assertEqual(self.data["labels"], [None, "blocked", "blocked", None, "stopped"])
|
||||
|
||||
def test_operator_commands_stay_off_the_board_and_queue_jobs_stay_on_it(self):
|
||||
self.assertEqual(self.data["onBoard"], 12)
|
||||
placed = [task for lane in self.data["shown"]["lanes"] for cells in lane[3].values() for task in cells]
|
||||
self.assertEqual(len(placed), 12)
|
||||
self.assertEqual([task for task in placed if task.startswith(("operator/", "queue/"))], ["queue/gate-1"])
|
||||
|
||||
def test_lanes_are_p1_to_p3_p4_plus_and_unpinned_by_wsjf_rank_and_old_done_cards_hide(self):
|
||||
self.assertEqual((self.data["lanes"], self.data["freeFrom"]), ([["p1", "P1"], ["p2", "P2"], ["p3", "P3"], ["p4", "P4+"], ["unpinned", "Unpinned"]], 4))
|
||||
self.assertEqual(self.data["lanesOf"], ["unpinned", "p1", "p1", "p1", "p2", "p3", "p4", "p4"])
|
||||
self.assertEqual(self.data["belowOne"], [["p1", 2], ["p2", 0], ["p3", 0], ["p4", 0], ["unpinned", 0]])
|
||||
empty = {"queued": [], "running": [], "done": []}
|
||||
self.assertEqual(self.data["hidden"], {"hidden": 1, "lanes": [
|
||||
["p1", "P1", 1, {**empty, "running": ["work/p1"]}],
|
||||
["p2", "P2", 1, {**empty, "queued": ["work/p2"]}],
|
||||
["p3", "P3", 1, {**empty, "running": ["work/p3"]}],
|
||||
["p4", "P4+", 2, {**empty, "queued": ["work/p4"], "done": ["work/p7"]}],
|
||||
["unpinned", "Unpinned", 6, {**empty, "queued": ["work/also", "work/high", "work/low", "work/none"], "running": ["queue/gate-1"], "done": ["work/fresh"]}],
|
||||
]})
|
||||
self.assertEqual(self.data["shown"]["hidden"], 0)
|
||||
self.assertEqual(self.data["shown"]["lanes"][4][3]["done"], ["work/fresh", "work/old"])
|
||||
|
||||
def test_a_card_shows_every_refusal_of_its_newest_batch_and_how_much_of_it_applied(self):
|
||||
self.assertEqual(self.data["why"], {
|
||||
"work/x": {"failed": ["status"], "applied": 1, "total": 2},
|
||||
"work/y": {"failed": ["lone"], "applied": 0, "total": 1},
|
||||
"work/z": {"failed": ["cmd"], "applied": 0, "total": 1},
|
||||
"work/w": None,
|
||||
"work/v": None,
|
||||
"work/none": None,
|
||||
})
|
||||
|
||||
def test_p4_plus_takes_the_lowest_pin_from_4_no_other_open_task_holds(self):
|
||||
self.assertEqual(self.data["pins"], [5, 6, 4])
|
||||
|
||||
def test_moves_stage_one_change_each_and_a_later_move_of_the_same_kind_replaces_it(self):
|
||||
self.assertEqual(self.data["first"], [
|
||||
["work/none", "set work/none --status running"],
|
||||
["work/none", "set work/none --prio 2"],
|
||||
["work/low", "set work/low --prio 5"],
|
||||
["work/high", "set work/high --prio 6"],
|
||||
["work/p1", "set work/p1 --unpin"],
|
||||
["work/none", "set work/none --after work/p1"],
|
||||
])
|
||||
self.assertEqual(self.data["replaced"], [*self.data["first"][1:], ["work/none", "set work/none --status done"]])
|
||||
self.assertEqual(self.data["back"], [
|
||||
["work/low", "set work/low --prio 5"], ["work/high", "set work/high --prio 6"], ["work/p1", "set work/p1 --unpin"], ["work/none", "set work/none --status done"],
|
||||
])
|
||||
|
||||
def test_staged_moves_show_at_once_as_pending_rows(self):
|
||||
self.assertEqual(self.data["applied"], {
|
||||
"work/p1": ["running", False, None, [], 1],
|
||||
"work/low": ["queued", False, 5, [], 1],
|
||||
"work/high": ["queued", False, 6, [], 1],
|
||||
"work/none": ["done", True, None, [], 1],
|
||||
})
|
||||
self.assertEqual(self.data["untouched"], 9)
|
||||
self.assertEqual(self.data["pendingAfter"], ["work/p2", "work/p3"])
|
||||
|
||||
def test_a_move_to_where_the_card_already_is_stages_nothing(self):
|
||||
self.assertEqual(self.data["noops"], [None] * 8)
|
||||
self.assertEqual(self.data["moves"], [["set", "work/none", "--status", "stopped"], ["set", "work/dep", "--drop-after", "work/p1"], ["set", "work/fresh", "--status", "running"]])
|
||||
|
||||
def test_arrows_run_into_the_dependent_card_several_per_card_and_the_queue_line_keeps_queue_order(self):
|
||||
self.assertEqual(self.data["lines"], {"arrows": [{"from": "a", "to": "b"}, {"from": "a", "to": "c"}, {"from": "b", "to": "c"}], "queue": ["c", "a"]})
|
||||
|
||||
|
||||
class TestForecast(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
done = subprocess.run(["node", "-e", FORECAST_PROBE, str(PLAN)], capture_output=True, text=True, timeout=60, check=True)
|
||||
cls.data = json.loads(done.stdout)
|
||||
|
||||
def assertDays(self, found: list, expected: list) -> None:
|
||||
for got, want in zip(found, expected, strict=True):
|
||||
if want is None:
|
||||
self.assertIsNone(got)
|
||||
else:
|
||||
self.assertAlmostEqual(got, want, places=9)
|
||||
|
||||
def test_at_fixed_scope_both_finishes_meet(self):
|
||||
self.assertDays(self.data["fixedScope"], [4, 4])
|
||||
|
||||
def test_a_growing_scope_finishes_later_than_the_fixed_one(self):
|
||||
self.assertDays(self.data["growing"], [3, 4])
|
||||
self.assertDays(self.data["velocity"], [3, 4])
|
||||
|
||||
def test_a_finish_that_is_never_reached_is_none_and_shown_as_infinity(self):
|
||||
self.assertDays(self.data["growingNever"], [6, None])
|
||||
self.assertDays(self.data["never"], [None, None])
|
||||
self.assertEqual(self.data["noTrend"], {"fixed": None, "growing": None})
|
||||
self.assertIsNone(self.data["beyond"]["growing"])
|
||||
self.assertIsNone(self.data["beyond"]["fixed"])
|
||||
|
||||
def test_nothing_remaining_finishes_at_the_last_point(self):
|
||||
self.assertDays(self.data["finished"], [2, 2])
|
||||
|
||||
def test_a_task_without_a_register_change_for_three_days_is_stale(self):
|
||||
self.assertEqual((self.data["staleDays"], self.data["stale"]), (3, [True, False, False, False, False]))
|
||||
|
||||
def test_the_scope_leaves_out_operator_commands_and_queue_jobs_and_counts_stale_tasks_apart(self):
|
||||
self.assertEqual(self.data["all"], {"rows": ["work/b", "work/c"], "stale": ["work/a"], "from": 94, "to": 100})
|
||||
self.assertEqual(self.data["filter"], {"rows": ["work/b", "work/c"], "stale": ["work/a"], "from": 99, "to": 100})
|
||||
self.assertEqual((self.data["burn"]["scope"], self.data["burn"]["done"]), (2, 1))
|
||||
|
||||
|
||||
class TestBoardArgv(unittest.TestCase):
|
||||
def test_the_signed_register_contract_takes_exactly_one_board_change(self):
|
||||
for flags in (["--status", "running"], ["--status", "stopped"], ["--after", "work/b"], ["--drop-after", "work/b"], ["--prio", "4"], ["--unpin"]):
|
||||
with self.subTest(flags=flags):
|
||||
argv = ["set", "work/a", *flags]
|
||||
self.assertEqual(signing.task_argv("work/a", argv), argv)
|
||||
for flags in (["--status", "paused"], ["--status"], ["--after", "work/a"], ["--after", "operator/push"], ["--after", "Work/B"], ["--unpin", "--prio", "4"], ["--status", "done", "--prio", "2"], ["--drop-after"], ["--title", "x"], []):
|
||||
with self.subTest(flags=flags), self.assertRaises(signing.Refused):
|
||||
signing.task_argv("work/a", ["set", "work/a", *flags])
|
||||
|
||||
|
||||
class TestHubKanban(HubCase):
|
||||
def board_register(self) -> dict:
|
||||
return {
|
||||
"work/a": {"title": "A", "status": "queued", "worker": "Stage", "roles": {"Stage": "RA"}, "key_results": [{"text": "shipped", "met": False}], "updated": iso(time.time() - 4 * DAY)},
|
||||
"work/b": {"title": "B", "status": "running", "worker": "Docs", "after": ["work/a"], "updated": iso(time.time() - DAY)},
|
||||
"work/c": {"title": "C", "status": "queued", "worker": "Stage"},
|
||||
"work/d": {"title": "D", "status": "done", "worker": "Docs", "end": iso(time.time() - 2 * DAY)},
|
||||
"work/e": {"title": "E", "status": "queued", "worker": "-"},
|
||||
"operator/push": {"title": "git push", "workdir": "/repo", "status": "queued"},
|
||||
}
|
||||
|
||||
def test_the_state_carries_the_change_time_the_movable_rows_and_the_queue_line(self):
|
||||
now = time.time()
|
||||
register = self.board_register()
|
||||
entries = [
|
||||
{"id": "gate-1", "seq": 1, "kind": "gate", "task": "work/c", "name": "Stage", "hotfix": None, "status": "active", "enqueued": now - 300, "granted": now - 200, "cmd": "make test"},
|
||||
{"id": "gate-2", "seq": 2, "kind": "gate", "task": "work/a", "name": "Stage", "hotfix": None, "status": "waiting", "enqueued": now - 100, "granted": None, "cmd": "make test"},
|
||||
{"id": "gate-3", "seq": 3, "kind": "gate", "task": "work/c", "name": "Stage", "hotfix": None, "status": "waiting", "enqueued": now - 50, "granted": None, "cmd": "make test"},
|
||||
{"id": "gate-4", "seq": 4, "kind": "gate", "task": None, "name": "Stage", "hotfix": None, "status": "waiting", "enqueued": now - 40, "granted": None, "cmd": "make test"},
|
||||
]
|
||||
self.write_council({"rasci.json": register, "queue.json": {"seq": 4, "entries": entries}})
|
||||
state = self.snapshot()
|
||||
rows = {row["task"]: row for row in state["tasks"]}
|
||||
self.assertAlmostEqual(rows["work/a"]["updated"], now - 4 * DAY, delta=2)
|
||||
self.assertIsNone(rows["work/c"]["updated"])
|
||||
self.assertEqual({task: (row["movable"], row["editable"]) for task, row in rows.items() if not task.startswith("queue/")}, {
|
||||
"work/a": (True, True), "work/b": (True, True), "work/c": (True, True), "work/d": (True, False), "work/e": (True, True), "operator/push": (False, False),
|
||||
})
|
||||
self.assertEqual((rows["queue/gate-1"]["movable"], rows["queue/gate-1"]["updated"]), (False, None))
|
||||
self.assertEqual(state["queue_line"], ["work/c", "work/a"])
|
||||
|
||||
def test_a_signed_board_batch_reaches_the_register_through_the_runner_and_tells_each_worker_once(self):
|
||||
self.write_council({"rasci.json": self.board_register()})
|
||||
self.claim("s1", "Stage session")
|
||||
signer = self.pin()
|
||||
items = [
|
||||
change(["set", "work/c", "--status", "running"], "work/c"),
|
||||
change(["set", "work/c", "--prio", "4"], "work/c"),
|
||||
change(["set", "work/a", "--after", "work/c"], "work/a"),
|
||||
change(["set", "work/b", "--drop-after", "work/a"], "work/b"),
|
||||
change(["set", "work/d", "--status", "running"], "work/d"),
|
||||
change(["set", "work/e", "--unpin"], "work/e"),
|
||||
]
|
||||
status, _, body = self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))
|
||||
self.assertEqual(status, 202, body)
|
||||
self.assertEqual((len(json.loads(body)["jobs"]), self.scripts.calls), (6, []))
|
||||
self.run_runner()
|
||||
self.assertEqual(self.scripts.calls, [
|
||||
(skill_scripts.RADIO, ["post", "--to", "Stage session", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/c --status running; rasci.py set work/c --prio 4; rasci.py set work/a --after work/c"]),
|
||||
(skill_scripts.RADIO, ["post", "--to", "Docs", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/b --drop-after work/a; rasci.py set work/d --status running"]),
|
||||
])
|
||||
stored = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual((stored["work/c"]["status"], stored["work/c"]["prio"], stored["work/a"]["after"], "after" in stored["work/b"]), ("running", 4, ["work/c"], False))
|
||||
self.assertEqual((stored["work/d"]["status"], "end" in stored["work/d"], "prio" in stored["work/e"]), ("running", False, False))
|
||||
self.assertTrue(all("updated" in stored[task] for task in ("work/a", "work/b", "work/c", "work/d")))
|
||||
self.assertEqual([job["status"] for job in self.snapshot()["jobs"] if job["kind"] == "task"], ["done"] * 6)
|
||||
|
||||
def test_the_executor_refuses_a_cycle_a_done_with_an_open_key_result_and_a_forged_change_and_says_why(self):
|
||||
self.write_council({"rasci.json": self.board_register()})
|
||||
before = (self.council / "rasci.json").read_bytes()
|
||||
signer = self.pin()
|
||||
items = [change(["set", "work/a", "--after", "work/b"], "work/a"), change(["set", "work/a", "--status", "done"], "work/a")]
|
||||
proof = self.signed(items, signer)
|
||||
status, _, body = self.post_json("/api/tasks", {"items": items, "proof": proof}, self.guard(self.host))
|
||||
self.assertEqual(status, 202, body)
|
||||
fields = {"kind": "task", "task": "work/a", "argv": ["set", "work/a", "--status", "stopped"], "command": "x", "directory": "", "sha256": "", "status": "queued", "proof": proof}
|
||||
forged = self.httpd.hub.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})
|
||||
self.run_runner()
|
||||
jobs = {job["command"]: job for job in self.snapshot()["jobs"] if job["kind"] == "task"}
|
||||
cycle, done = jobs["rasci.py set work/a --after work/b"], jobs["rasci.py set work/a --status done"]
|
||||
self.assertEqual((cycle["status"], cycle["code"]), ("failed", 2))
|
||||
self.assertIn("rasci: work/a: --after work/b closes a cycle", cycle["output"])
|
||||
self.assertEqual((done["status"], done["code"]), ("failed", 2))
|
||||
self.assertIn("rasci: work/a: 1 key result(s) open", done["output"])
|
||||
record = self.httpd.hub.jobs.get(forged["id"])
|
||||
self.assertEqual(record["status"], "refused")
|
||||
self.assertTrue(record["output"].startswith(runner.REFUSED), record["output"])
|
||||
self.assertIn("does not list this item", record["output"])
|
||||
self.assertEqual((self.council / "rasci.json").read_bytes(), before)
|
||||
self.assertEqual(self.scripts.calls, [])
|
||||
|
||||
def test_only_the_changes_rasci_applied_are_told_once_their_whole_batch_ended(self):
|
||||
self.write_council({"rasci.json": self.board_register()})
|
||||
signer = self.pin()
|
||||
items = [change(["set", "work/a", "--status", "done"], "work/a"), change(["set", "work/a", "--prio", "2"], "work/a")]
|
||||
status, _, body = self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))
|
||||
self.assertEqual((status, self.scripts.calls), (202, []), body)
|
||||
self.run_runner()
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["post", "--to", "Stage", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/a --prio 2"])])
|
||||
jobs = [job for job in self.snapshot()["jobs"] if job["kind"] == "task"]
|
||||
self.assertEqual(sorted(job["status"] for job in jobs), ["done", "failed"])
|
||||
self.assertEqual(len({job["batch"] for job in jobs}), 1)
|
||||
self.assertEqual(self.httpd.hub.tell_applied(jobs[0]), [])
|
||||
self.assertEqual(len(self.scripts.calls), 1)
|
||||
|
||||
def test_the_hub_signs_a_status_move_of_an_ended_task_but_no_other_change_of_it_and_no_operator_row(self):
|
||||
self.write_council({"rasci.json": self.board_register()})
|
||||
good = self.guard(self.host)
|
||||
self.assertEqual(self.post_json("/api/sign", {"items": [change(["set", "work/d", "--status", "queued"], "work/d")]}, good)[0], 200)
|
||||
for items, code in (
|
||||
([change(["set", "work/d", "--prio", "2"], "work/d")], 409),
|
||||
([change(["set", "work/d", "--after", "work/a"], "work/d")], 409),
|
||||
([change(["set", "operator/push", "--status", "done"], "operator/push")], 400),
|
||||
([change(["set", "work/a", "--after", "operator/push"], "work/a")], 400),
|
||||
([change(["set", "work/a", "--status", "done", "--prio", "1"], "work/a")], 400),
|
||||
):
|
||||
with self.subTest(items=items):
|
||||
self.assertEqual(self.post_json("/api/sign", {"items": items}, good)[0], code)
|
||||
self.assertEqual(self.scripts.calls, [])
|
||||
|
||||
def test_the_page_ships_the_board_modules_and_every_board_control_is_a_labelled_icon(self):
|
||||
html = self.index()
|
||||
self.assertIn(KANBAN.read_text(encoding="utf-8").strip(), html)
|
||||
self.assertIn("function kanbanBoard(shown)", html)
|
||||
self.assertIn(".kanban-lines", html)
|
||||
self.assertIn("const TASK_COLUMNS = Kanban.COLUMNS;", html)
|
||||
page = (HUB / "templates" / "page-kanban.js").read_text(encoding="utf-8")
|
||||
controls = [line for line in page.splitlines() if re.search(r'h\("(?:button|summary|span)", (?:\{|named)', line) and "icon(" in line or 'h("button"' in line]
|
||||
self.assertGreaterEqual(len(controls), 6)
|
||||
for line in controls:
|
||||
with self.subTest(line=line.strip()[:90]):
|
||||
self.assertTrue("named(" in line or "title:" in line, line)
|
||||
self.assertNotIn("innerHTML", page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -151,7 +151,7 @@ class TestHubState(HubCase):
|
||||
set(state),
|
||||
{
|
||||
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
|
||||
"register", "tasks", "task_edges", "agent_order", "radio", "claims", "grants", "night", "night_parts", "presence", "silence", "graph",
|
||||
"register", "tasks", "task_edges", "queue_line", "agent_order", "radio", "claims", "grants", "night", "night_parts", "presence", "silence", "graph",
|
||||
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
|
||||
"badges", "kinds",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user