feat(orchestrator): S5.1 radio spot, compact menus, trend, ETA and Tasks 3D
🧪 Test / 🧪 Lock + lint (push) Canceled after 0s
🧪 Test / 🧪 Lock + lint (push) Canceled after 0s
Requirement 001 decisions 52 to 58 (operator 2026-11-09): - Radio spot: the Agents toolbar radio button expands inline with the one composer (every channel, post and call recipients, Record, Send), a gear with the radio settings (tones, squelch, read aloud, moved out of the general settings) and Cancel mayday, which leaves the toolbar; the Radio tab keeps speakers, stations and the log plus a jump to the spot. - Day/Night menu: the badge and Drain, Suspend and Resume only; each explanation is the button's tooltip. - Tasks toolbar: views, filter, estimation, sort and business value in one wrapping row with a Request choices dropdown; the status chips stay the second row. - Burndown and burnup: a trend selector (off, linear regression, velocity over N days) stored in the hub settings, the trend lines and the forecast date on the chart, and a tooltip per point with the work packages that started, ended or joined there. - ETA: every list row shows its ETA (end when done, else start or now plus the forecast length behind its predecessors), sharing one predecessor walk with the Gantt. - Tasks 3D: a sixth view on the 3D engine with the dependency edges in the Gantt colours, layouts Force and Time. - Steady 3D camera: the view fits once per graph build, refreshes keep the operator's camera. - e2e: Firefox runs with its profile as cwd, so its crashreporter no longer drops core dumps into the repository. - A forecast beyond the last second a Date can hold is no forecast, so a nearly flat regression no longer aborts the Tasks render. Evidence: make test Ran 717 OK, ruff clean, markdownlint 0 errors; e2e 30 of 30 OK in Chromium and Firefox at 1920, 1366 and 390 px; MEDUSA 0 findings after one skeptic-accepted lexical EXA006. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e94f2800d5
commit
57ab29eb0d
25 files changed
+1390
-215
No files matched your search
+2
-1
@@ -17,7 +17,8 @@ const port = await new Promise((resolve) => {
|
||||
probe.close(() => resolve(free));
|
||||
});
|
||||
});
|
||||
const browser = spawn(binary, ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "about:blank"], {stdio: ["ignore", "ignore", "pipe"]});
|
||||
// Trip-wire: Firefox still starts "crashreporter --ping-cleanup", which ignores MOZ_CRASHREPORTER_DISABLE and can dump core.<pid> into its cwd; the throwaway profile takes it.
|
||||
const browser = spawn(binary, ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "about:blank"], {cwd: profile, stdio: ["ignore", "ignore", "pipe"], env: {...process.env, MOZ_CRASHREPORTER_DISABLE: "1"}});
|
||||
|
||||
const address = await new Promise((resolve, reject) => {
|
||||
let text = "";
|
||||
|
||||
@@ -34,7 +34,7 @@ from test_hub_offices import host as office_host
|
||||
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
|
||||
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||||
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
||||
TASK_VIEWS = ("list", "board", "rasci", "gantt", "burn")
|
||||
TASK_VIEWS = ("list", "board", "rasci", "gantt", "burn", "3d")
|
||||
SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)]
|
||||
SUBS = ["Inbox", "Firewall", "Radio"]
|
||||
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
|
||||
@@ -54,6 +54,21 @@ HELPERS = """
|
||||
const all = (selector, root) => [...(root || document).querySelectorAll(selector)];
|
||||
const text = (node) => (node ? node.textContent.trim() : null);
|
||||
const name = (node) => (node ? node.getAttribute("aria-label") || text(node) : null);
|
||||
const toolbarRows = () => {
|
||||
const bar = document.querySelector('#main [role="toolbar"][aria-label="Tasks"]');
|
||||
const boxes = bar ? [...bar.children].filter((node) => node.getClientRects().length).map((node) => node.getBoundingClientRect()).sort((a, b) => a.top - b.top) : [];
|
||||
let [rows, bottom] = [0, -Infinity];
|
||||
for (const box of boxes) {
|
||||
if (box.top >= bottom - 1) {
|
||||
rows += 1;
|
||||
bottom = box.bottom;
|
||||
} else {
|
||||
bottom = Math.max(bottom, box.bottom);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
const taskLayout = () => all("#main .graph.tasks > *").map((node) => (node.matches('[role="toolbar"]') ? "toolbar" : node.matches('[aria-label="Task filters"]') ? "chips" : "body"));
|
||||
"""
|
||||
PROBE = """(async () => {@HELPERS@
|
||||
await until(() => !document.querySelector("#main .space-note") || !text(document.querySelector("#main .space-note")).startsWith("Loading"), 8000);
|
||||
@@ -81,7 +96,11 @@ PROBE = """(async () => {@HELPERS@
|
||||
replay: Boolean(document.querySelector("#time .replay input[type=range]")),
|
||||
custom: Boolean(document.querySelector('#main details.menu input[type="datetime-local"]')),
|
||||
toolbar: all('#main [role="toolbar"] > *').map((node) => name(node.tagName === "DETAILS" ? node.querySelector("summary") : node.querySelector("button") || node)),
|
||||
toAll: all("#to-all input[type=radio]").map((input) => [input.value, input.checked]),
|
||||
spot: (() => {
|
||||
const box = document.getElementById("radio-spot");
|
||||
return box ? {hidden: box.hidden, channels: [...box.querySelector("form.composer select").options].map((option) => option.value)} : null;
|
||||
})(),
|
||||
composers: all("#main form.composer").map((form) => (form.closest("#radio-spot") ? "spot" : "other")),
|
||||
inbox: all("#main .inbox-list > li").length,
|
||||
icons: icons.length,
|
||||
unlabelled: icons.map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).map((owner) => owner.outerHTML.slice(0, 160)),
|
||||
@@ -188,7 +207,8 @@ ACTIONS = """(async () => {@HELPERS@
|
||||
document.getElementById("settings-open").click();
|
||||
await sleep(300);
|
||||
found.ambience = all(".sheet-box form > section:first-child button").map((button) => button.id);
|
||||
found.speech = all('.sheet-box [aria-label="Classes"] input').map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
|
||||
found.sections = all(".sheet-box form.settings > section > h3").map(text);
|
||||
found.radioFields = all('.sheet-box [aria-label="Squelch"], .sheet-box [aria-label="Classes"], .sheet-box [aria-label="Speakers"], .sheet-box .sound-row').length;
|
||||
found.settingsUnlabelled = all(".sheet-box svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length;
|
||||
document.querySelector(".sheet-head button").click();
|
||||
document.getElementById("palette-open").click();
|
||||
@@ -526,8 +546,16 @@ SPEAK_END = """(async () => {@HELPERS@@SPEAKING@
|
||||
await sleep(300);
|
||||
return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length, faces: all("#main .avatar.speaking").length};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
||||
COMPOSE = """(async () => {@HELPERS@
|
||||
const form = await until(() => document.querySelector("#main form.composer"), 8000);
|
||||
SPOT_OPEN = """
|
||||
const spotToggle = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Radio"), 8000);
|
||||
if (spotToggle && spotToggle.getAttribute("aria-expanded") !== "true") {
|
||||
spotToggle.click();
|
||||
await sleep(300);
|
||||
}
|
||||
const spot = document.getElementById("radio-spot");
|
||||
"""
|
||||
COMPOSE = """(async () => {@HELPERS@@SPOT_OPEN@
|
||||
const form = spot ? spot.querySelector("form.composer") : null;
|
||||
if (!form) {
|
||||
return {error: "no composer"};
|
||||
}
|
||||
@@ -558,7 +586,7 @@ COMPOSE = """(async () => {@HELPERS@
|
||||
return clashes(form);
|
||||
})(),
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN).replace("@CLASHES@", OVERLAP)
|
||||
COMPOSE_SEND = """(async () => {@HELPERS@
|
||||
const form = document.querySelector("#main form.composer");
|
||||
const field = form.querySelector("textarea");
|
||||
@@ -571,15 +599,36 @@ COMPOSE_SEND = """(async () => {@HELPERS@
|
||||
return {sent, left: field.value, voice: Boolean(form.querySelector(".voice-note audio"))};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
CLEAR_MAYDAY = """(async () => {@HELPERS@
|
||||
window.confirm = () => true;
|
||||
const button = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Cancel mayday" && !node.disabled), 8000);
|
||||
const questions = [];
|
||||
let accept = false;
|
||||
window.confirm = (question) => {
|
||||
questions.push(question);
|
||||
return accept;
|
||||
};
|
||||
const posted = [];
|
||||
const send = window.fetch.bind(window);
|
||||
window.fetch = (path, init) => {
|
||||
if (String(path).startsWith("/api/board/")) {
|
||||
posted.push(String(path));
|
||||
}
|
||||
return send(path, init);
|
||||
};
|
||||
await until(() => document.querySelector('#main [role="toolbar"]'), 8000);
|
||||
const toolbar = all('#main [role="toolbar"] button, #main [role="toolbar"] summary').map(name);@SPOT_OPEN@
|
||||
const button = await until(() => spot && all(".spot-head button", spot).find((node) => name(node) === "Cancel mayday" && !node.disabled), 8000);
|
||||
if (!button) {
|
||||
return {error: "no enabled Cancel mayday button"};
|
||||
return {error: "no enabled Cancel mayday button in the radio spot", toolbar};
|
||||
}
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
const fresh = () => all("#toasts .toast").filter((node) => !shown.has(node)).map(text);
|
||||
button.click();
|
||||
const done = await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Cancel mayday")), 15000);
|
||||
return {done, toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
await sleep(800);
|
||||
const declined = {question: questions.slice(), posted: posted.slice(), toasts: fresh()};
|
||||
accept = true;
|
||||
button.click();
|
||||
const done = await until(() => fresh().find((toast) => toast.startsWith("Cancel mayday")), 15000);
|
||||
return {toolbar, title: button.title, declined, accepted: {question: questions.slice(declined.question.length), posted: posted.slice(declined.posted.length)}, done};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN)
|
||||
NIGHT_MENU = """(async () => {@HELPERS@
|
||||
const questions = [];
|
||||
window.confirm = (question) => {
|
||||
@@ -618,6 +667,187 @@ NIGHT_MENU = """(async () => {@HELPERS@
|
||||
}
|
||||
return {labels: buttons.map(name), runs};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
NIGHT_LOOK = """(async () => {@HELPERS@
|
||||
const menu = await until(() => all('#main [role="toolbar"] details.menu').find((node) => name(node.querySelector("summary")) === "Day/Night"), 8000);
|
||||
if (!menu) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
menu.open = true;
|
||||
await sleep(200);
|
||||
const box = menu.querySelector(".menu-box");
|
||||
return {
|
||||
children: [...box.children].map((node) => [node.tagName.toLowerCase(), node.className]),
|
||||
badge: all(".pill", box).map(text),
|
||||
buttons: all("button", box).map((button) => [name(button), button.title]),
|
||||
paragraphs: all("p", box).length,
|
||||
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
FITS = "tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1"
|
||||
SPOT_SEND = """(async () => {@HELPERS@
|
||||
const questions = [];
|
||||
window.confirm = (question) => {
|
||||
questions.push(question);
|
||||
return true;
|
||||
};
|
||||
const toggle = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Radio"), 8000);
|
||||
if (!toggle) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const bar = document.querySelector('#main [role="toolbar"]');
|
||||
const spot = document.getElementById("radio-spot");
|
||||
const closed = [spot.hidden, toggle.getAttribute("aria-expanded")];
|
||||
toggle.click();
|
||||
await sleep(400);
|
||||
const form = spot.querySelector("form.composer");
|
||||
const [channel, recipient] = all("select", form);
|
||||
const field = form.querySelector("textarea");
|
||||
const found = {
|
||||
closed,
|
||||
open: [spot.hidden, toggle.getAttribute("aria-expanded"), document.activeElement === field],
|
||||
inline: [getComputedStyle(spot).position, spot.getBoundingClientRect().top >= bar.getBoundingClientRect().bottom, spot.previousElementSibling === bar],
|
||||
toolbar: all('#main [role="toolbar"] > *').map((node) => name(node.tagName === "DETAILS" ? node.querySelector("summary") : node)),
|
||||
channels: all("option", channel).map((option) => option.value),
|
||||
record: all("button", form).filter((button) => button.hasAttribute("aria-pressed")).map(text),
|
||||
heads: all(".spot-head button", spot).map((button) => [name(button), button.title]),
|
||||
@FITS@,
|
||||
};
|
||||
await until(() => [...recipient.options].some((option) => option.value === "Stage"), 8000);
|
||||
found.runs = [];
|
||||
for (const cls of found.channels) {
|
||||
channel.value = cls;
|
||||
channel.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
const direct = !recipient.closest("label").hidden;
|
||||
if (direct) {
|
||||
recipient.value = "Stage";
|
||||
}
|
||||
field.value = `@TEXT@ ${cls}`;
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
const prompts = questions.length;
|
||||
form.querySelector('button[type="submit"]').click();
|
||||
const toast = await until(() => all("#toasts .toast").filter((node) => !shown.has(node)).map(text).find((entry) => entry.includes(" to ")), 20000);
|
||||
found.runs.push({cls, direct, toast, question: questions.slice(prompts), left: field.value});
|
||||
}
|
||||
document.getElementById("toasts").replaceChildren();
|
||||
return found;
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@FITS@", FITS)
|
||||
RADIO_JUMP = """(async () => {@HELPERS@
|
||||
const jump = await until(() => all("#main .radio-view button").find((button) => name(button) === "Send …"), 8000);
|
||||
if (!jump) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const composers = all("#main form.composer").length;
|
||||
jump.click();
|
||||
const spot = await until(() => location.hash.startsWith("#agents/") && document.querySelector("#radio-spot:not([hidden])"), 4000);
|
||||
await sleep(200);
|
||||
return {composers, hash: location.hash, open: Boolean(spot), focused: Boolean(spot) && document.activeElement === spot.querySelector("textarea")};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
GEAR_SAVE = """(async () => {@HELPERS@@CLASHES@
|
||||
const spot = document.getElementById("radio-spot");
|
||||
const gear = all(".spot-head button", spot).find((button) => name(button) === "Radio settings");
|
||||
gear.click();
|
||||
const form = await until(() => spot.querySelector("form.radio-settings"), 4000);
|
||||
if (!form) {
|
||||
return {error: "no radio settings behind the gear"};
|
||||
}
|
||||
const classes = () => all('[aria-label="Classes"] input', form);
|
||||
const found = {
|
||||
expanded: gear.getAttribute("aria-expanded"),
|
||||
sections: all("section > h3", form).map(text),
|
||||
tones: all(".sound-row strong", form).map(text),
|
||||
classes: classes().map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]),
|
||||
clashes: clashes(form),
|
||||
@FITS@,
|
||||
};
|
||||
const squelch = form.querySelector('input[aria-label="Squelch"]');
|
||||
squelch.value = "@SQUELCH@";
|
||||
squelch.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
form.querySelector('input[aria-label="All radio on"]').checked = @TONE@;
|
||||
classes().find((input) => input.parentElement.textContent.trim() === "Post").checked = @POST@;
|
||||
const before = new Set(all("#toasts .toast"));
|
||||
all("button", form).find((button) => button.type === "submit").click();
|
||||
found.toast = await until(() => all("#toasts .toast").filter((node) => !before.has(node)).map(text).find((entry) => entry.includes("ettings")), 15000);
|
||||
await sleep(300);
|
||||
found.after = [gear.getAttribute("aria-expanded"), Boolean(spot.querySelector("form.radio-settings"))];
|
||||
document.getElementById("settings-open").click();
|
||||
const sheet = await until(() => document.querySelector(".sheet-box form.settings"), 4000);
|
||||
found.general = {sections: all("section > h3", sheet).map(text), radio: all('[aria-label="Squelch"], [aria-label="Classes"], [aria-label="Speakers"], .sound-row', sheet).length};
|
||||
document.querySelector(".sheet-head button").click();
|
||||
return found;
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP).replace("@FITS@", FITS)
|
||||
GEAR_READ = """(async () => {@HELPERS@@SPOT_OPEN@
|
||||
const gear = spot ? all(".spot-head button", spot).find((button) => name(button) === "Radio settings") : null;
|
||||
if (!gear) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
gear.click();
|
||||
const form = await until(() => spot.querySelector("form.radio-settings"), 4000);
|
||||
return {
|
||||
squelch: Number(form.querySelector('input[aria-label="Squelch"]').value),
|
||||
tone: form.querySelector('input[aria-label="All radio on"]').checked,
|
||||
post: all('[aria-label="Classes"] input', form).find((input) => input.parentElement.textContent.trim() === "Post").checked,
|
||||
@FITS@,
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN).replace("@FITS@", FITS)
|
||||
GRAPH_HOOK = """() => {
|
||||
let made = null;
|
||||
Object.defineProperty(window, "ForceGraph3D", {
|
||||
configurable: true,
|
||||
get: () => made && ((options) => {
|
||||
const graph = made(options);
|
||||
window.e2eGraphs = [...(window.e2eGraphs || []), graph];
|
||||
return graph;
|
||||
}),
|
||||
set: (value) => {
|
||||
made = value;
|
||||
},
|
||||
});
|
||||
}"""
|
||||
CAMERA = """(async () => {@HELPERS@
|
||||
const ready = await until(() => window.e2eGraphs && document.querySelector("#main .graph3d canvas") && !text(document.querySelector("#main .space-note")).startsWith("Loading"), 15000);
|
||||
if (!ready) {
|
||||
return {error: text(document.querySelector("#main .graph"))};
|
||||
}
|
||||
const graph = window.e2eGraphs[window.e2eGraphs.length - 1];
|
||||
const position = () => {
|
||||
const at = graph.cameraPosition();
|
||||
return [at.x, at.y, at.z];
|
||||
};
|
||||
await sleep(5500);
|
||||
let [fed, stops] = [0, 0];
|
||||
const stopped = graph.onEngineStop();
|
||||
graph.onEngineStop(() => {
|
||||
stops += 1;
|
||||
stopped();
|
||||
});
|
||||
const feed = graph.graphData;
|
||||
graph.graphData = function (...args) {
|
||||
fed += args.length;
|
||||
return feed.apply(graph, args);
|
||||
};
|
||||
const built = position();
|
||||
const canvas = document.querySelector("#main .graph3d canvas");
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
for (let turn = 0; turn < 4; turn += 1) {
|
||||
canvas.dispatchEvent(new WheelEvent("wheel", {deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true}));
|
||||
await sleep(60);
|
||||
}
|
||||
await sleep(900);
|
||||
const moved = position();
|
||||
const refreshes = [];
|
||||
for (let round = 0; round < 2; round += 1) {
|
||||
const [feeds, ends] = [fed, stops];
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
const rebuilt = Boolean(await until(() => fed > feeds, 8000));
|
||||
const settled = Boolean(await until(() => stops > ends, 10000));
|
||||
await sleep(900);
|
||||
refreshes.push({rebuilt, settled, camera: position()});
|
||||
}
|
||||
all("#main button").find((button) => name(button) === "Reset view").click();
|
||||
await sleep(1500);
|
||||
return {hooked: typeof stopped === "function", built, moved, refreshes, reset: position()};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
|
||||
|
||||
def tone(seconds: int, rate: int) -> bytes:
|
||||
@@ -748,25 +978,26 @@ WATCH = """
|
||||
}
|
||||
return send(path, init);
|
||||
};
|
||||
const bar = () => document.querySelector('#main [aria-label="Request choices"]');
|
||||
const bar = () => document.getElementById("choice-request");
|
||||
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});
|
||||
"""
|
||||
CHOICE_VIEW = """(async () => {@HELPERS@@LABELS@
|
||||
const found = await until(() => document.querySelector('#main [aria-label="Request choices"]') && all("#main .inbox-list > li .options").length === @CARDS@ && document.querySelector('#main [aria-label="Request choices"] details.menu input[type="checkbox"]'), 8000);
|
||||
const found = await until(() => document.getElementById("choice-request") && all("#main .inbox-list > li .options").length === @CARDS@ && document.querySelector('#choice-request input[type="checkbox"]'), 8000);
|
||||
if (!found) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const box = document.querySelector('#main [aria-label="Request choices"]');
|
||||
const menu = box.querySelector("details.menu");
|
||||
const box = document.getElementById("choice-request");
|
||||
const chip = document.querySelector('#main [aria-label="Task filters"] button[data-filter="operator"]');
|
||||
const closed = all("button[data-channel]", box).filter((button) => button.checkVisibility()).length;
|
||||
return {
|
||||
kinds: [...new Set(all("#main .inbox-list > li").map((li) => text(li.querySelector(".pill"))))].sort(),
|
||||
filter: [text(chip).replace(/[0-9]+$/, ""), chip.getAttribute("aria-pressed")],
|
||||
channels: all("button[data-channel]", box).map((button) => [button.dataset.channel, name(button), button.disabled]),
|
||||
fields: all("textarea, input:not([type=checkbox])", box).length,
|
||||
agents: all('input[type="checkbox"]', menu).map((input) => input.value),
|
||||
multi: [name(menu.querySelector("summary")), text(menu.querySelector("summary .count")), all('input[type="checkbox"]', menu).filter((input) => input.checked).length],
|
||||
agents: all('input[type="checkbox"]', box).map((input) => input.value),
|
||||
multi: [name(box.querySelector("summary")), box.open, closed, text(box.querySelector(".menu-box h3 .count")), all('input[type="checkbox"]', box).filter((input) => input.checked).length],
|
||||
bar: [box.parentElement.getAttribute("aria-label"), toolbarRows(), taskLayout()],
|
||||
cards: all("#main .inbox-list > li").filter((li) => li.querySelector(".options")).map((li) => [text(li.querySelector(".pill")), all(".option strong", li).map(text)]),
|
||||
choices: all("#main [data-column], #main .group-title, #main h2, #main h3").filter((node) => node.dataset.column === "choices" || text(node) === "Choices").length,
|
||||
unlabelled: unlabelled(document),
|
||||
@@ -775,13 +1006,13 @@ CHOICE_VIEW = """(async () => {@HELPERS@@LABELS@
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
CHOICE_SEND = """(async () => {@HELPERS@@WATCH@
|
||||
const menu = bar().querySelector("details.menu");
|
||||
menu.open = true;
|
||||
const menu = bar();
|
||||
menu.querySelector("summary").click();
|
||||
await sleep(200);
|
||||
const boxes = all('input[type="checkbox"]', menu).slice(0, 2);
|
||||
boxes.forEach((box) => box.click());
|
||||
const counted = text(menu.querySelector("summary .count"));
|
||||
menu.open = false;
|
||||
const counted = text(menu.querySelector(".menu-box h3 .count"));
|
||||
const opened = [menu.open, all("button[data-channel]", menu).filter((button) => button.checkVisibility()).length];
|
||||
const picked = boxes.map((box) => box.value).sort((a, b) => a.localeCompare(b));
|
||||
const runs = [];
|
||||
for (const button of all("button[data-channel]", bar())) {
|
||||
@@ -792,9 +1023,12 @@ CHOICE_SEND = """(async () => {@HELPERS@@WATCH@
|
||||
const toast = await fresh(shown, "Choice request");
|
||||
runs.push({channel: button.dataset.channel, disabled, asked: asked - before, question: questions.slice(prompts), posted: posted.slice(sent), toast});
|
||||
}
|
||||
return {picked, counted, runs, fields: all("textarea, input:not([type=checkbox])", bar()).length, ...fits()};
|
||||
return {picked, counted, opened, runs, fields: all("textarea, input:not([type=checkbox])", bar()).length, ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@WATCH@", WATCH)
|
||||
CHOICE_ANSWER = """(async () => {@HELPERS@@WATCH@
|
||||
bar().querySelector("summary").click();
|
||||
await sleep(200);
|
||||
const closed = [bar().open, all("button[data-channel]", bar()).filter((button) => button.checkVisibility()).length];
|
||||
const card = await until(() => all("#main .inbox-list > li").find((li) => li.textContent.includes("@QUESTION@")), 8000);
|
||||
if (!card) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
@@ -805,15 +1039,17 @@ CHOICE_ANSWER = """(async () => {@HELPERS@@WATCH@
|
||||
option.click();
|
||||
const toast = await fresh(shown, "Choice: Wait");
|
||||
const gone = @SIGNS@ ? Boolean(await until(() => !all("#main .inbox-list > li").some((li) => li.textContent.includes("@QUESTION@")), 20000)) : null;
|
||||
return {title, asked, posted, toast, gone, ...fits()};
|
||||
return {title, asked, posted, toast, gone, closed, ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@WATCH@", WATCH)
|
||||
TASKS_FIT = """(async () => {@HELPERS@
|
||||
await until(() => document.querySelector('#main [aria-label="Request choices"]') && document.querySelector("#main .tasks-body > *"), 8000);
|
||||
await until(() => document.getElementById("choice-request") && document.querySelector("#main .tasks-body > *"), 8000);
|
||||
document.getElementById("toasts").replaceChildren();
|
||||
await sleep(300);
|
||||
return {
|
||||
hash: location.hash,
|
||||
bar: all('#main [aria-label="Request choices"] button[data-channel]').map((button) => button.dataset.channel),
|
||||
bar: all("#choice-request button[data-channel]").map((button) => button.dataset.channel),
|
||||
rows: toolbarRows(),
|
||||
layout: taskLayout(),
|
||||
columns: all("#main .task-column").map((column) => column.dataset.column),
|
||||
choices: all("#main [data-column], #main .group-title, #main h2, #main h3").filter((node) => node.dataset.column === "choices" || text(node) === "Choices").length,
|
||||
tall: document.documentElement.scrollHeight > window.innerHeight,
|
||||
@@ -821,7 +1057,7 @@ TASKS_FIT = """(async () => {@HELPERS@
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
PLAN_HELPERS = """
|
||||
const fits = () => ({tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1});
|
||||
const fits = () => ({tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1, rows: toolbarRows(), layout: taskLayout()});
|
||||
const modes = () => all('#main [aria-label="Estimation"] button').map((button) => [button.dataset.estimation, button.getAttribute("aria-pressed")]);
|
||||
const requestShown = () => {
|
||||
const button = document.getElementById("request-estimates");
|
||||
@@ -939,10 +1175,146 @@ BULK = """(async () => {@HELPERS@@PLAN_HELPERS@
|
||||
found.reset = Boolean(await until(() => requestShown() === false, 8000));
|
||||
return found;
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
|
||||
ETA_LIST = """(async () => {@HELPERS@@PLAN_HELPERS@
|
||||
const list = await until(() => all("#main .task-row .task-eta").length >= 8, 8000);
|
||||
if (!list) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const number = (value) => (value === "" ? null : Number(value));
|
||||
const read = () => Object.fromEntries(all("#main .task-row").map((row) => {
|
||||
const eta = row.querySelector(".task-eta");
|
||||
return [row.dataset.task, [number(eta.dataset.start), number(eta.dataset.eta), eta.dataset.forecast === "true", text(eta), eta.title]];
|
||||
}));
|
||||
const toggle = async (mode) => {
|
||||
const before = JSON.stringify(read());
|
||||
all('#main [aria-label="Estimation"] button').find((button) => button.dataset.estimation === mode).click();
|
||||
await until(() => JSON.stringify(read()) !== before, 8000);
|
||||
await sleep(300);
|
||||
return read();
|
||||
};
|
||||
const found = {none: read(), modes: modes(), ...fits()};
|
||||
found.points = await toggle("points");
|
||||
found.back = await toggle("none");
|
||||
return found;
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
|
||||
TREND_READ = """
|
||||
const trendOf = () => {
|
||||
const select = document.querySelector('#main select[aria-label="Trend"]');
|
||||
const days = document.querySelector('#main input[aria-label="Velocity days"]');
|
||||
const note = document.querySelector("#main .burn-forecast");
|
||||
const marker = document.querySelector("#main svg.burn .forecast-line");
|
||||
return {
|
||||
value: select.value, options: all("option", select).map((option) => [option.value, text(option)]), days: [Number(days.value), days.getClientRects().length > 0],
|
||||
note: note ? [note.dataset.forecast === "" ? null : Number(note.dataset.forecast), text(note)] : null, marker: marker ? Number(marker.dataset.forecast) : null,
|
||||
lines: all("#main svg.burn path.burn-trend").map((path) => [path.dataset.series, Number(path.dataset.from), Number(path.dataset.to)]),
|
||||
points: all('#main svg.burn circle.burn-point[data-series="remaining"]').map((circle) => [Number(circle.dataset.t), Number(circle.dataset.value)]),
|
||||
};
|
||||
};
|
||||
const chart = async () => until(() => document.querySelector("#main svg.burn") && document.querySelector('#main select[aria-label="Trend"]'), 8000);
|
||||
"""
|
||||
TREND_SET = """(async () => {@HELPERS@@PLAN_HELPERS@@TREND_READ@
|
||||
if (!(await chart())) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const before = trendOf();
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
await choose(document.querySelector('#main select[aria-label="Trend"]'), "@MODE@");
|
||||
const toasts = [await fresh(shown, "Trend: ")];
|
||||
if ("@MODE@" === "velocity") {
|
||||
const days = document.querySelector('#main input[aria-label="Velocity days"]');
|
||||
const again = new Set(all("#toasts .toast"));
|
||||
days.value = "@DAYS@";
|
||||
days.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
toasts.push(await fresh(again, "Trend: velocity over @DAYS@ days"));
|
||||
}
|
||||
await sleep(400);
|
||||
return {before, after: trendOf(), toasts, ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS).replace("@TREND_READ@", TREND_READ)
|
||||
TREND_RELOADED = """(async () => {@HELPERS@@PLAN_HELPERS@@TREND_READ@
|
||||
if (!(await chart())) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
await sleep(300);
|
||||
return {...trendOf(), ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS).replace("@TREND_READ@", TREND_READ)
|
||||
STEP_TIPS = """(async () => {@HELPERS@@PLAN_HELPERS@@TREND_READ@
|
||||
if (!(await chart())) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const steps = all('#main svg.burn circle.burn-point[data-series="remaining"]').map((circle) => ({
|
||||
t: Number(circle.dataset.t), started: circle.dataset.started, ended: circle.dataset.ended, joined: circle.dataset.joined, tip: text(circle.querySelector("title")),
|
||||
series: all(`#main svg.burn circle.burn-point[data-step="${circle.dataset.step}"]`).map((mark) => mark.dataset.series),
|
||||
}));
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
await choose(document.querySelector('#main select[aria-label="Trend"]'), "off");
|
||||
const reset = await fresh(shown, "Trend: off");
|
||||
await sleep(300);
|
||||
return {steps, reset, note: Boolean(document.querySelector("#main .burn-forecast")), lines: all("#main svg.burn path.burn-trend").length, ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS).replace("@TREND_READ@", TREND_READ)
|
||||
TASKS_3D = """(async () => {@HELPERS@@PLAN_HELPERS@
|
||||
const note = () => text(document.querySelector("#main .space-note")) || "";
|
||||
const ready = await until(() => window.e2eGraphs && document.querySelector("#main .graph3d canvas") && !note().startsWith("Loading"), 15000);
|
||||
if (!ready) {
|
||||
return {error: text(document.querySelector("#main .graph"))};
|
||||
}
|
||||
const graph = () => window.e2eGraphs[window.e2eGraphs.length - 1];
|
||||
const id = (end) => (typeof end === "object" ? end.id : end);
|
||||
const look = () => {
|
||||
const data = graph().graphData();
|
||||
const color = graph().linkColor();
|
||||
return {
|
||||
note: note(), layouts: all('#main [aria-label="Layout"] button').map((button) => [button.dataset.layout, button.getAttribute("aria-pressed")]),
|
||||
nodes: data.nodes.map((node) => [node.id, Number.isFinite(node.fz) ? node.fz : null, Number.isFinite(node.ts) ? node.ts : null]),
|
||||
links: data.links.map((link) => [id(link.source), id(link.target), link.kind, color(link)]),
|
||||
label: (() => {
|
||||
const shown = graph().nodeLabel()(data.nodes.find((node) => node.id === "work/t5"));
|
||||
return shown instanceof Element ? shown.textContent : String(shown);
|
||||
})(),
|
||||
};
|
||||
};
|
||||
const layout = async (mode, word) => {
|
||||
all('#main [aria-label="Layout"] button').find((button) => button.dataset.layout === mode).click();
|
||||
await until(() => note().endsWith(word), 8000);
|
||||
await sleep(400);
|
||||
return look();
|
||||
};
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const palette = Object.fromEntries(["violet", "info", "warn"].map((role) => [role, css.getPropertyValue(`--${role}`).trim()]));
|
||||
const force = look();
|
||||
const time = await layout("time", "Time");
|
||||
const field = document.querySelector('#main input[aria-label="Find a node"]');
|
||||
field.value = "work/t5";
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
await sleep(200);
|
||||
const chip = all("#main .hits .chip").find((node) => node.dataset.hit === "work/t5");
|
||||
if (!chip) {
|
||||
return {error: "no search hit", force, time};
|
||||
}
|
||||
chip.click();
|
||||
await sleep(600);
|
||||
const dialog = document.getElementById("popup");
|
||||
const eta = dialog.querySelector(".task-eta");
|
||||
const popup = {
|
||||
open: dialog.open, title: text(dialog.querySelector(".popup-box .panel-title")), id: text(dialog.querySelector(".popup-head .task-id")), picked: document.querySelector("#main .graph3d").dataset.picked || null,
|
||||
eta: eta ? eta.dataset.eta : null, open_button: name(dialog.querySelector(".popup-body .actions button")), line: dialog.querySelector(".leader line").getAttribute("display") !== "none",
|
||||
};
|
||||
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||||
await sleep(300);
|
||||
popup.closed = [dialog.open, document.querySelector("#main .graph3d").dataset.picked || null];
|
||||
const back = await layout("none", "Force");
|
||||
await sleep(1200);
|
||||
return {palette, force, time, popup, back: back.note, ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
|
||||
PLAN_VALUES = {1920: "13", 1366: "21", 390: "34"}
|
||||
TREND_DAYS = {1920: 7, 1366: 3, 390: 14}
|
||||
STATUS_ORDER = ["queued", "awaiting", "claimed", "running", "blocked", "stale", "done", "stopped"]
|
||||
CHOICE_REQUEST = "send me your open decisions as choices"
|
||||
CHOICE_CHANNELS = ["post", "call", "newsletter", "broadcast", "pan-pan", "security", "mayday"]
|
||||
NIGHT_HINTS = [
|
||||
("Drain", "Drain: the agents finish their task and take nothing new; your passkey signs it"),
|
||||
("Suspend", "Suspend: the agents park their work, write the handover and idle; your passkey signs it"),
|
||||
("Resume", "Resume: all agents continue from their handover; your passkey signs it"),
|
||||
]
|
||||
SLOW ="seq -f 'Row %g' 1 60; sleep 4; seq -f 'Row %g' 61 80; sleep 4; echo finished"
|
||||
|
||||
|
||||
@@ -1248,6 +1620,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
|
||||
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
||||
self.assertEqual((probe["panel"], probe["nodeChips"]), (0, 0))
|
||||
self.assertEqual(probe["composers"], ["spot"] if probe["hash"].startswith("#agents/") else [])
|
||||
self.assertEqual([probe["tall"] for probe in probes if probe["hash"] == "#agents/orbit"], [False] * len(WIDTHS))
|
||||
by_hash = {probe["hash"]: probe for probe in probes}
|
||||
stream = by_hash["#stream"]
|
||||
@@ -1261,8 +1634,8 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
|
||||
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ nodes · \d+ edges · Force$")
|
||||
agents = by_hash["#agents/orbit"]
|
||||
self.assertEqual(agents["toolbar"], ["Radio to all", "Cancel mayday", "Priority", "Day/Night"])
|
||||
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
|
||||
self.assertEqual(agents["toolbar"], ["Radio", "Priority", "Day/Night"])
|
||||
self.assertEqual(agents["spot"], {"hidden": True, "channels": CHOICE_CHANNELS})
|
||||
hierarchy = by_hash["#agents/hierarchy"]
|
||||
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
|
||||
self.assertEqual(hierarchy["toggles"], [["Collapse: Operator", "true"], ["Collapse: Chair", "true"], ["Collapse: identity", "true"], ["Collapse: Presidents", "true"]])
|
||||
@@ -1286,7 +1659,10 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(by_hash["#artifacts/chord"]["flowEdges"], ["pull", "push"])
|
||||
order = ["work/feature", "work/docs", "work/stage", "queue/gate-3", "work/old", "operator/push"]
|
||||
listed = by_hash["#tasks/list"]
|
||||
self.assertEqual(listed["views"], ["List", "Board", "RASCI", "Gantt", "Burndown and burnup"])
|
||||
self.assertEqual(listed["views"], ["List", "Board", "RASCI", "Gantt", "Burndown and burnup", "3D"])
|
||||
self.assertEqual(by_hash["#tasks/3d"]["layouts"], ["Force", "Time"])
|
||||
self.assertTrue(by_hash["#tasks/3d"]["canvas"], by_hash["#tasks/3d"]["note"])
|
||||
self.assertRegex(by_hash["#tasks/3d"]["note"], r"^6 nodes · \d+ edges · Force$")
|
||||
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"])
|
||||
@@ -1297,12 +1673,11 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual([probe["hash"] for probe in extra[:3]], ["#agents/orbit", "#agents/chord", "#artifacts/tree"])
|
||||
actions = extra[3]
|
||||
self.assertNotIn("error", actions)
|
||||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/burn", "#stream"])
|
||||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/3d", "#stream"])
|
||||
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
|
||||
self.assertEqual((actions["settingsUnlabelled"], actions["popupUnlabelled"]), (0, 0))
|
||||
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"])
|
||||
self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]])
|
||||
for tool in ("Radio to all …", "Cancel mayday", "Priority …", "Drain", "Suspend", "Resume", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"):
|
||||
self.assertEqual((actions["sections"], actions["radioFields"]), (["Ambience", "Location", "Appearance", "Music", "Notifications", "Internet radio", "Passkey"], 0))
|
||||
for tool in ("Radio …", "Cancel mayday", "Priority …", "Drain", "Suspend", "Resume", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"):
|
||||
with self.subTest(tool=tool):
|
||||
self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"])
|
||||
self.assertEqual(actions["popup"], [["post", True], ["call", False]])
|
||||
@@ -1376,10 +1751,10 @@ class TestOrchestratorE2E(HubCase):
|
||||
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
|
||||
local = f"http://localhost:{self.port}/"
|
||||
steps = [
|
||||
{"width": 1366, "height": 768, "url": f"{local}#stream/radio", "wait": 1200, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": self.shot("1366-radio-composer"), "gesture": True},
|
||||
{"width": 1366, "height": 768, "url": f"{local}#agents/orbit", "wait": 1200, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": self.shot("1366-radio-spot-composer"), "gesture": True},
|
||||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "false"), "shot": None},
|
||||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": None, "gesture": True},
|
||||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "true"), "shot": self.shot("1366-radio-composer-sent")},
|
||||
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "true"), "shot": self.shot("1366-radio-spot-composer-sent")},
|
||||
]
|
||||
data = self.drive(steps, self.authenticator())
|
||||
(OUT / "chromium-composer.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
@@ -1406,14 +1781,90 @@ class TestOrchestratorE2E(HubCase):
|
||||
status, _, body = self.get(f"/api/voice/{note}.wav")
|
||||
self.assertEqual((status, body[:4]), (200, b"RIFF"))
|
||||
|
||||
def test_chromium_clears_the_mayday_only_after_the_passkey_signs(self):
|
||||
def test_chromium_clears_the_mayday_from_the_radio_spot_only_after_confirmation_and_passkey_at_three_widths(self):
|
||||
local = f"http://localhost:{self.port}/"
|
||||
data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#agents/orbit", "wait": 1200, "probe": CLEAR_MAYDAY, "shot": self.shot("1366-mayday-cleared")}], self.authenticator())
|
||||
steps = [{"width": width, "height": height, "url": f"{local}?{width}#agents/orbit", "wait": 1200, "probe": CLEAR_MAYDAY, "shot": self.shot(f"{width}-radio-spot-mayday-cleared")} for width, height in WIDTHS]
|
||||
data = self.drive(steps, self.authenticator())
|
||||
(OUT / "chromium-mayday.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual(data["errors"], [])
|
||||
result = data["results"][0]
|
||||
self.assertNotIn("error", result)
|
||||
self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result)
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
|
||||
question = "Lift the radio silence of the active mayday?"
|
||||
for result, (width, _) in zip(data["results"], WIDTHS, strict=True):
|
||||
with self.subTest(width=width):
|
||||
self.assertNotIn("error", result)
|
||||
self.assertEqual(result["toolbar"], ["Radio", "Priority", "Day/Night", "Drain", "Suspend", "Resume"])
|
||||
self.assertEqual(result["title"], "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it")
|
||||
self.assertEqual(result["declined"], {"question": [question], "posted": [], "toasts": []})
|
||||
self.assertEqual(result["accepted"], {"question": [question], "posted": ["/api/board/sign", "/api/board/clear-mayday"]})
|
||||
self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result)
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])] * len(WIDTHS))
|
||||
|
||||
def test_chromium_sends_every_class_from_the_radio_spot_and_keeps_its_gear_settings_through_a_reload_at_three_widths(self):
|
||||
local = f"http://localhost:{self.port}/"
|
||||
values = {1920: ("0.35", "false", "true"), 1366: ("0.45", "true", "false"), 390: ("0.55", "false", "true")}
|
||||
steps = [{"width": 1366, "height": 768, "url": f"{local}#stream/radio", "wait": 1200, "probe": RADIO_JUMP, "shot": self.shot("1366-radio-tab-jump-to-spot")}]
|
||||
for width, height in WIDTHS:
|
||||
squelch, tone, post = values[width]
|
||||
gear = GEAR_SAVE.replace("@SQUELCH@", squelch).replace("@TONE@", tone).replace("@POST@", post)
|
||||
steps += [
|
||||
{"width": width, "height": height, "url": f"{local}?{width}#agents/orbit", "wait": 1200, "probe": SPOT_SEND.replace("@TEXT@", f"spot {width}"), "shot": self.shot(f"{width}-radio-spot-sent")},
|
||||
{"width": width, "height": height, "url": None, "wait": 0, "probe": gear, "shot": None},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-reloaded#agents/orbit", "wait": 1200, "probe": GEAR_READ, "shot": self.shot(f"{width}-radio-spot-gear")},
|
||||
]
|
||||
data = self.drive(steps, self.authenticator())
|
||||
(OUT / "chromium-radio-spot.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual(data["errors"], [])
|
||||
labels = dict(zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True))
|
||||
mayday = "Send a mayday to all? All agents pause writing steps until you cancel it."
|
||||
jump, *walked = data["results"]
|
||||
self.assertEqual(jump, {"composers": 0, "hash": "#agents/orbit", "open": True, "focused": True})
|
||||
for index, (width, _) in enumerate(WIDTHS):
|
||||
sent, saved, read = walked[3 * index : 3 * (index + 1)]
|
||||
squelch, tone, post = values[width]
|
||||
with self.subTest(width=width):
|
||||
for probe in (sent, saved, read):
|
||||
self.assertNotIn("error", probe)
|
||||
self.assertEqual((sent["closed"], sent["open"]), ([True, "false"], [False, "true", True]))
|
||||
self.assertEqual(sent["inline"], ["static", True, True])
|
||||
self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Radio", "Priority", "Day/Night"], CHOICE_CHANNELS, ["Record"]))
|
||||
self.assertEqual(sent["heads"], [["Radio settings", "Radio settings: tones, squelch and read aloud"], ["Cancel mayday", "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it"]])
|
||||
self.assertEqual((sent["tall"], sent["wide"]), (False, False))
|
||||
self.assertEqual([run["cls"] for run in sent["runs"]], CHOICE_CHANNELS)
|
||||
for run in sent["runs"]:
|
||||
direct = run["cls"] in ("post", "call")
|
||||
self.assertEqual((run["direct"], run["question"], run["left"]), (direct, [mayday] if run["cls"] == "mayday" else [], ""), run)
|
||||
self.assertTrue((run["toast"] or "").startswith(f"{labels[run['cls']]} to {'Stage' if direct else 'all'}: sent"), run)
|
||||
self.assertEqual((saved["expanded"], saved["sections"], saved["clashes"]), ("true", ["Tones", "Read radio aloud"], {"found": [], "outside": []}))
|
||||
self.assertEqual(saved["tones"], ["MAYDAY / PAN-PAN", "Reviews, asks, decisions", "Post and calls to you", "All radio"])
|
||||
self.assertEqual([entry[0] for entry in saved["classes"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"])
|
||||
self.assertEqual([entry[1:] for entry in saved["classes"] if entry[0] == "MAYDAY"], [[True, True]])
|
||||
self.assertEqual((saved["toast"], saved["after"]), ("Radio settings saved", ["false", False]))
|
||||
self.assertEqual(saved["general"], {"sections": ["Ambience", "Location", "Appearance", "Music", "Notifications", "Internet radio", "Passkey"], "radio": 0})
|
||||
self.assertEqual((saved["tall"], saved["wide"]), (False, False))
|
||||
self.assertEqual(read, {"squelch": float(squelch), "tone": tone == "true", "post": post == "true", "tall": False, "wide": False})
|
||||
heads = {"post": ["post", "--to", "Stage"], "call": ["send", "call", "--to", "Stage"], "newsletter": ["post"]}
|
||||
radio = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
|
||||
sends = [(width, cls) for width, _ in WIDTHS for cls in CHOICE_CHANNELS]
|
||||
self.assertEqual([args[:-1] for args in radio], [[*heads.get(cls, ["send", cls]), "--session", "Operator"] for _, cls in sends])
|
||||
for args, (width, cls) in zip(radio, sends, strict=True):
|
||||
with self.subTest(width=width, cls=cls):
|
||||
self.assertTrue(args[-1].endswith(f"] spot {width} {cls}"), args)
|
||||
stored = self.httpd.hub.settings.get()
|
||||
self.assertEqual((stored["squelch"], stored["sounds"]["radio"]["on"], "post" in stored["speech"]["classes"]), (0.55, False, True))
|
||||
|
||||
def test_chromium_keeps_the_3d_camera_through_two_state_refreshes_at_three_widths(self):
|
||||
steps = [{"width": width, "height": height, "url": f"{self.base}?{width}#agents/3d", "wait": 900, "probe": CAMERA, "shot": self.shot(f"{width}-agents-3d-steady-camera")} for width, height in WIDTHS]
|
||||
data = self.drive(steps, None, init=GRAPH_HOOK)
|
||||
(OUT / "chromium-camera.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual(data["errors"], [])
|
||||
for result, (width, _) in zip(data["results"], WIDTHS, strict=True):
|
||||
with self.subTest(width=width):
|
||||
self.assertNotIn("error", result)
|
||||
self.assertTrue(result["hooked"], result)
|
||||
self.assertGreater(math.dist(result["built"], result["moved"]), 1, result)
|
||||
for refresh in result["refreshes"]:
|
||||
self.assertEqual((refresh["rebuilt"], refresh["settled"]), (True, True), result)
|
||||
self.assertLess(math.dist(refresh["camera"], result["moved"]), 0.01, result)
|
||||
self.assertGreater(math.dist(result["reset"], result["moved"]), 1, result)
|
||||
|
||||
def check_night_menu(self, browser: str, driver: str, binary: str, signs: bool) -> None:
|
||||
"""Args:
|
||||
@@ -1423,11 +1874,18 @@ class TestOrchestratorE2E(HubCase):
|
||||
"""
|
||||
authenticator = self.authenticator()
|
||||
probe = NIGHT_MENU.replace("@SIGNS@", "true" if signs else "false")
|
||||
looks = [{"width": width, "height": height, "url": f"http://localhost:{self.port}/?{width}#agents/orbit", "wait": 1200, "probe": NIGHT_LOOK, "shot": str(OUT / f"{browser}-{width}-night-menu-tooltips.png")} for width, height in WIDTHS]
|
||||
step = {"width": 1366, "height": 768, "url": f"http://localhost:{self.port}/#agents/orbit", "wait": 1200, "probe": probe, "shot": str(OUT / f"{browser}-1366-night-menu.png")}
|
||||
data = self.drive([step], authenticator if signs else None, driver, binary)
|
||||
data = self.drive([*looks, step], authenticator if signs else None, driver, binary)
|
||||
(OUT / f"{browser}-night-menu.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||||
found = data["results"][0]
|
||||
*seen, found = data["results"]
|
||||
for look, (width, _) in zip(seen, WIDTHS, strict=True):
|
||||
with self.subTest(browser=browser, width=width):
|
||||
self.assertNotIn("error", look)
|
||||
self.assertEqual(look["children"], [["span", ""], ["div", "actions"]])
|
||||
self.assertEqual((look["badge"], look["paragraphs"], look["wide"]), (["Day"], 0, False))
|
||||
self.assertEqual(look["buttons"], [list(entry) for entry in NIGHT_HINTS])
|
||||
self.assertNotIn("error", found)
|
||||
self.assertEqual(found["labels"], ["Drain", "Suspend", "Resume"])
|
||||
for run, action in zip(found["runs"], ("drain", "suspend", "resume"), strict=True):
|
||||
@@ -1663,6 +2121,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
labels = dict(zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True))
|
||||
self.assertEqual(len(sent["picked"]), 2, sent)
|
||||
self.assertEqual((sent["counted"], sent["fields"], sent["tall"], sent["wide"]), ("2", 0, False, False))
|
||||
self.assertEqual(sent["opened"], [True, len(CHOICE_CHANNELS)])
|
||||
self.assertEqual([run["channel"] for run in sent["runs"]], CHOICE_CHANNELS)
|
||||
for run in sent["runs"]:
|
||||
with self.subTest(channel=run["channel"]):
|
||||
@@ -1675,7 +2134,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
|
||||
def answered(self, result: dict, signs: bool) -> None:
|
||||
self.assertNotIn("error", result)
|
||||
self.assertEqual((result["title"], result["asked"], result["tall"], result["wide"]), ("Wait: your passkey signs this answer, a post to Stage", 1, False, False))
|
||||
self.assertEqual((result["title"], result["asked"], result["closed"], result["tall"], result["wide"]), ("Wait: your passkey signs this answer, a post to Stage", 1, [False, 0], False, False))
|
||||
signed = [["/api/sign", ["items"], None, None]]
|
||||
self.assertEqual(result["posted"], [*signed, ["/api/inbox/approve", ["items", "proof"], None, None]] if signs else signed)
|
||||
self.assertTrue((result["toast"] or "").startswith("Choice: Wait" + ("" if signs else ": not signed")), result)
|
||||
@@ -1703,13 +2162,16 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(view["kinds"], ["Ask", "Choice", "Operator", "Review", "Rule request"])
|
||||
self.assertEqual(view["filter"], ["Waits on Operator", "true"])
|
||||
self.assertEqual(view["channels"], [[channel, label, channel in ("post", "call")] for channel, label in zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True)])
|
||||
self.assertEqual((view["fields"], view["choices"], view["multi"], view["unlabelled"], view["tall"], view["wide"]), (0, 0, ["Agents", "0", 0], [], False, False))
|
||||
self.assertEqual((view["fields"], view["choices"], view["multi"], view["unlabelled"], view["tall"], view["wide"]), (0, 0, ["Request choices", False, 0, "0", 0], [], False, False))
|
||||
self.assertEqual(view["bar"], ["Tasks", view["bar"][1], ["toolbar", "chips", "body"]])
|
||||
self.assertEqual(view["bar"][1] == 1, width != 390, view["bar"])
|
||||
self.assertGreaterEqual(len(view["agents"]), 2, view)
|
||||
self.assertIn(["Choice", ["Merge now", "Wait"]], view["cards"])
|
||||
self.requested(sent, signs)
|
||||
self.answered(answer, signs)
|
||||
for fit, name in zip(fitted, TASK_VIEWS, strict=True):
|
||||
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"])
|
||||
hub = self.httpd.hub
|
||||
@@ -1818,7 +2280,8 @@ class TestOrchestratorE2E(HubCase):
|
||||
with self.subTest(browser=browser, width=width):
|
||||
for probe in (gantt, burn, points_on, request, gantt_points, bulk):
|
||||
self.assertNotIn("error", probe)
|
||||
self.assertEqual((probe["tall"], probe["wide"]), (False, False), probe)
|
||||
self.assertEqual((probe["tall"], probe["wide"], probe["layout"]), (False, False, ["toolbar", "chips", "body"]), probe)
|
||||
self.assertEqual(probe["rows"] == 1, width != 390, probe)
|
||||
self.planned(gantt, points=False)
|
||||
self.planned(gantt_points, points=True)
|
||||
self.assertEqual((burn["modes"], burn["request"]), ([["none", "true"], ["points", "false"]], False))
|
||||
@@ -1862,6 +2325,179 @@ class TestOrchestratorE2E(HubCase):
|
||||
def test_firefox_draws_the_gantt_and_burn_and_asks_the_passkey_for_a_bulk_business_value_storing_nothing_unsigned(self):
|
||||
self.check_plan("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False)
|
||||
|
||||
def trend_work(self, now: float) -> None:
|
||||
day = 86400
|
||||
register = {
|
||||
"work/t1": {"title": "First", "status": "done", "worker": "Stage", "start": iso(now - 10 * day), "end": iso(now - 8 * day), "roles": {"Stage": "RA"}, "estimates": {"Stage": {"value": 5, "complexity": 1}}},
|
||||
"work/t2": {"title": "Second", "status": "done", "worker": "Docs", "start": iso(now - 10 * day), "end": iso(now - 6 * day), "roles": {"Docs": "RA"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
|
||||
"work/t3": {"title": "Third", "status": "done", "worker": "Docs", "branch": "feature/t3", "start": iso(now - 10 * day), "end": iso(now - 4 * day), "roles": {"Docs": "RA"}, "estimates": {"Docs": {"value": 3, "complexity": 3}}},
|
||||
"work/t4": {"title": "Fourth", "status": "running", "worker": "Stage", "start": iso(now - day), "roles": {"Stage": "RA"}, "estimates": {"Stage": {"value": 8, "complexity": 5}}},
|
||||
"work/t5": {"title": "Fifth", "status": "queued", "after": ["work/t4"], "roles": {"Skills": "R"}, "estimates": {"Skills": {"value": 8, "complexity": 3}}},
|
||||
"work/t6": {"title": "Sixth", "status": "queued", "base": "feature/t3", "roles": {"Docs": "RA"}},
|
||||
}
|
||||
queue = {"seq": 2, "entries": [
|
||||
{"id": "deploy-1", "seq": 1, "kind": "deploy", "task": "work/t4", "workdir": "/repo", "session": "s2", "name": "Stage", "hotfix": None, "status": "active", "enqueued": now - 400, "granted": now - 300, "cmd": "make deploy"},
|
||||
{"id": "test-2", "seq": 2, "kind": "test", "task": "work/t5", "workdir": "/repo", "session": "s2", "name": "Docs", "hotfix": None, "status": "waiting", "enqueued": now - 60, "granted": None, "cmd": "make test"},
|
||||
]}
|
||||
self.write_council({"rasci.json": register, "queue.json": queue})
|
||||
|
||||
def trend_steps(self, browser: str) -> list[dict]:
|
||||
local = f"http://localhost:{self.port}/"
|
||||
steps = []
|
||||
for width, height in WIDTHS:
|
||||
days = str(TREND_DAYS[width])
|
||||
steps += [
|
||||
{"width": width, "height": height, "url": f"{local}?{width}#tasks/list", "wait": 1200, "probe": ETA_LIST, "shot": str(OUT / f"{browser}-{width}-tasks-eta.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}#tasks/burn", "wait": 900, "probe": TREND_SET.replace("@MODE@", "regression").replace("@DAYS@", days), "shot": str(OUT / f"{browser}-{width}-tasks-burn-regression.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-regression#tasks/burn", "wait": 1200, "probe": TREND_RELOADED, "shot": None},
|
||||
{"width": width, "height": height, "url": None, "wait": 0, "probe": TREND_SET.replace("@MODE@", "velocity").replace("@DAYS@", days), "shot": str(OUT / f"{browser}-{width}-tasks-burn-velocity.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-velocity#tasks/burn", "wait": 1200, "probe": TREND_RELOADED, "shot": None},
|
||||
{"width": width, "height": height, "url": None, "wait": 0, "probe": STEP_TIPS, "shot": str(OUT / f"{browser}-{width}-tasks-burn-steps.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-3d#tasks/3d", "wait": 1200, "probe": TASKS_3D, "shot": str(OUT / f"{browser}-{width}-tasks-3d.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-camera#tasks/3d", "wait": 900, "probe": CAMERA, "shot": str(OUT / f"{browser}-{width}-tasks-3d-steady-camera.png")},
|
||||
]
|
||||
return steps
|
||||
|
||||
def eta_checked(self, probe: dict, mode: str, rows: dict, edges: list[dict], window: tuple[float, float]) -> None:
|
||||
"""Args:
|
||||
probe: task -> [start, eta, forecast, text, title] as the list shows it.
|
||||
mode: the estimation mode the list was drawn in.
|
||||
rows: the snapshot's task rows by task.
|
||||
edges: the snapshot's task edges.
|
||||
window: the earliest and latest time the page can have taken as now.
|
||||
"""
|
||||
day = 86400
|
||||
points = {"none": {}, "points": {"work/t4": 10 * day, "work/t5": 6 * day}}[mode]
|
||||
self.assertEqual(sorted(probe), sorted(rows))
|
||||
for task, (start, eta, forecast, words, title) in probe.items():
|
||||
with self.subTest(mode=mode, task=task):
|
||||
row = rows[task]
|
||||
self.assertTrue(words.startswith("ETA ") and words != "ETA –", words)
|
||||
if row["ended"]:
|
||||
self.assertEqual((eta, forecast, title), (row["end"], False, "ETA: the end of the ended task"))
|
||||
continue
|
||||
self.assertTrue(forecast)
|
||||
self.assertAlmostEqual(eta - start, points.get(task, 4 * day), delta=0.01)
|
||||
ahead = [probe[edge["from"]][1] for edge in edges if edge["to"] == task and edge["from"] in probe and edge["from"] != task]
|
||||
if row["start"] is not None:
|
||||
self.assertEqual(start, row["start"])
|
||||
elif ahead and max(ahead) > window[1]:
|
||||
self.assertAlmostEqual(start, max(ahead), delta=0.01)
|
||||
else:
|
||||
self.assertTrue(window[0] <= start <= window[1], (start, window))
|
||||
|
||||
def trend_forecast(self, points: list[list[float]], mode: str, days: int) -> tuple[float, float | None]:
|
||||
"""Args:
|
||||
points: [t, remaining] of the drawn burndown points.
|
||||
|
||||
Returns:
|
||||
The trend's start and the time its remaining line reaches 0
|
||||
(None when it never falls), computed here independently of plan.js.
|
||||
"""
|
||||
last_t, last_y = points[-1]
|
||||
if mode == "regression":
|
||||
mean_t = sum(t for t, _ in points) / len(points)
|
||||
mean_y = sum(y for _, y in points) / len(points)
|
||||
slope = sum((t - mean_t) * (y - mean_y) for t, y in points) / sum((t - mean_t) ** 2 for t, _ in points)
|
||||
return points[0][0], (mean_t - mean_y / slope if slope < 0 else None)
|
||||
start = max(points[0][0], last_t - days * 86400)
|
||||
before = [y for t, y in points if t <= start][-1]
|
||||
slope = (last_y - before) / (last_t - start)
|
||||
return start, (last_t - last_y / slope if slope < 0 else None)
|
||||
|
||||
def trended(self, probe: dict, mode: str, days: int) -> None:
|
||||
start, forecast = self.trend_forecast(probe["points"], mode, days)
|
||||
self.assertEqual(probe["value"], mode)
|
||||
self.assertEqual(probe["days"], [days, mode == "velocity"])
|
||||
self.assertEqual(sorted(series for series, *_ in probe["lines"]), ["done", "remaining", "scope"])
|
||||
for _, begin, end in probe["lines"]:
|
||||
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))
|
||||
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"])
|
||||
|
||||
def check_trend_eta_3d(self, browser: str, driver: str, binary: str) -> None:
|
||||
self.trend_work(time.time())
|
||||
begun = time.time()
|
||||
data = self.drive(self.trend_steps(browser), None, driver, binary, init=GRAPH_HOOK)
|
||||
ended = time.time()
|
||||
(OUT / f"{browser}-trend-eta-3d.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||||
snapshot = self.snapshot()
|
||||
rows = {row["task"]: row for row in snapshot["tasks"]}
|
||||
edges = snapshot["task_edges"]
|
||||
self.assertEqual(sorted([edge["kind"], edge["from"], edge["to"]] for edge in edges), [["after", "work/t4", "work/t5"], ["base", "work/t3", "work/t6"], ["queue", "queue/deploy-1", "queue/test-2"]])
|
||||
trends = [["off", "off"], ["regression", "linear regression"], ["velocity", "velocity"]]
|
||||
per = 8
|
||||
for index, (width, _) in enumerate(WIDTHS):
|
||||
eta, regression, reloaded, velocity, again, tips, shown, camera = data["results"][per * index : per * (index + 1)]
|
||||
days = TREND_DAYS[width]
|
||||
kept = TREND_DAYS[WIDTHS[index - 1][0]] if index else 7
|
||||
with self.subTest(browser=browser, width=width):
|
||||
for probe in (eta, regression, reloaded, velocity, again, tips, shown):
|
||||
self.assertNotIn("error", probe)
|
||||
self.assertEqual((probe["tall"], probe["wide"], probe["layout"]), (False, False, ["toolbar", "chips", "body"]), probe)
|
||||
self.assertEqual(probe["rows"] == 1, width != 390, probe["rows"])
|
||||
for mode in ("none", "points", "back"):
|
||||
self.eta_checked(eta[mode], "points" if mode == "points" else "none", rows, edges, (begun - 1, ended + 1))
|
||||
self.assertEqual(eta["modes"], [["none", "true"], ["points", "false"]])
|
||||
self.assertEqual((regression["before"]["value"], regression["before"]["options"], regression["before"]["note"], regression["before"]["lines"]), ("off", trends, None, []))
|
||||
self.assertEqual(regression["before"]["days"], [kept, False])
|
||||
self.assertEqual(regression["toasts"], ["Trend: linear regression"])
|
||||
self.trended(regression["after"], "regression", kept)
|
||||
self.trended(reloaded, "regression", kept)
|
||||
self.assertEqual(velocity["toasts"], [f"Trend: velocity over {kept} days", f"Trend: velocity over {days} days"])
|
||||
self.trended(velocity["after"], "velocity", days)
|
||||
self.trended(again, "velocity", days)
|
||||
self.assertEqual([step["t"] for step in tips["steps"]], [t for t, _ in again["points"]])
|
||||
listed = [[sorted(filter(None, step[key].split(" "))) for key in ("started", "ended", "joined")] for step in tips["steps"]]
|
||||
self.assertEqual(listed, [[["work/t1", "work/t2", "work/t3"], [], ["work/t5", "work/t6"]], [[], ["work/t1"], []], [[], ["work/t2"], []], [[], ["work/t3"], []], [["work/t4"], [], []], [[], [], []]])
|
||||
self.assertEqual([step["series"] for step in tips["steps"]], [["scope", "done", "remaining"]] * len(tips["steps"]))
|
||||
self.assertIn("Ended: work/t1", tips["steps"][1]["tip"])
|
||||
self.assertIn("No work package started, ended or joined the scope here", tips["steps"][-1]["tip"])
|
||||
self.assertEqual((tips["reset"], tips["note"], tips["lines"]), ("Trend: off", False, 0))
|
||||
self.tasks_3d(shown, snapshot)
|
||||
self.assertTrue(camera["hooked"], camera)
|
||||
self.assertGreater(math.dist(camera["built"], camera["moved"]), 1, camera)
|
||||
for refresh in camera["refreshes"]:
|
||||
self.assertEqual((refresh["rebuilt"], refresh["settled"]), (True, True), camera)
|
||||
self.assertLess(math.dist(refresh["camera"], camera["moved"]), 0.01, camera)
|
||||
self.assertEqual(self.httpd.hub.settings.get()["trend"], {"mode": "off", "days": TREND_DAYS[WIDTHS[-1][0]]})
|
||||
|
||||
def tasks_3d(self, shown: dict, snapshot: dict) -> None:
|
||||
tasks = sorted(row["task"] for row in snapshot["tasks"])
|
||||
colors = {"after": shown["palette"]["violet"], "base": shown["palette"]["info"], "queue": shown["palette"]["warn"]}
|
||||
edges = sorted([edge["from"], edge["to"], edge["kind"], colors[edge["kind"]]] for edge in snapshot["task_edges"])
|
||||
self.assertEqual(len(set(colors.values())), 3, colors)
|
||||
for layout, word in (("force", "Force"), ("time", "Time")):
|
||||
drawn = shown[layout]
|
||||
with self.subTest(layout=layout):
|
||||
self.assertEqual(drawn["note"], f"{len(tasks)} nodes · {len(edges)} edges · {word}")
|
||||
self.assertEqual(drawn["layouts"], [["none", str(layout == "force").lower()], ["time", str(layout == "time").lower()]])
|
||||
self.assertEqual(sorted(node for node, *_ in drawn["nodes"]), tasks)
|
||||
self.assertEqual(sorted(drawn["links"]), edges)
|
||||
self.assertEqual(drawn["label"], "work/t5 · Fifth")
|
||||
self.assertEqual([fz for _, fz, _ in shown["force"]["nodes"]], [None] * len(tasks))
|
||||
timed = sorted(shown["time"]["nodes"], key=lambda node: node[2])
|
||||
self.assertTrue(all(fz is not None for _, fz, _ in timed), timed)
|
||||
self.assertEqual([fz for _, fz, _ in timed], sorted(fz for _, fz, _ in timed))
|
||||
self.assertLess(timed[0][1], timed[-1][1])
|
||||
popup = shown["popup"]
|
||||
self.assertEqual((popup["open"], popup["title"], popup["id"], popup["picked"], popup["open_button"], popup["line"]), (True, "Fifth", "work/t5", "work/t5", "Open task", True), popup)
|
||||
self.assertTrue(popup["eta"], popup)
|
||||
self.assertEqual((popup["closed"], shown["back"]), ([False, None], f"{len(tasks)} nodes · {len(edges)} edges · Force"))
|
||||
|
||||
def test_chromium_draws_the_trend_forecast_point_tips_eta_and_tasks_3d_at_three_widths(self):
|
||||
self.check_trend_eta_3d("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||
def test_firefox_draws_the_trend_forecast_point_tips_eta_and_tasks_3d_at_three_widths(self):
|
||||
self.check_trend_eta_3d("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||||
|
||||
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
|
||||
signer = self.pin()
|
||||
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||||
|
||||
@@ -11,7 +11,7 @@ const N = require(process.argv[1]);
|
||||
const hashes = ["", "#stream", "#stream/inbox", "#stream/rules", "#stream/nope", "#agents/chord", "#agents/3d", "#agents", "#agents/nope",
|
||||
"#artifacts/tree", "#artifacts/orbit", "#graph", "#graph/swimlane", "#graph/orbit", "#tree", "#bogus", "#agents%2Fswimlane",
|
||||
"#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy", "#stream/radio", "#radio",
|
||||
"#tasks", "#tasks/board", "#tasks/rasci", "#tasks/orbit", "#agents/board", "#tasks/hierarchy", "#tasks/gantt", "#tasks/burn", "#agents/gantt"];
|
||||
"#tasks", "#tasks/board", "#tasks/rasci", "#tasks/orbit", "#agents/board", "#tasks/hierarchy", "#tasks/gantt", "#tasks/burn", "#agents/gantt", "#tasks/3d"];
|
||||
console.log(JSON.stringify({
|
||||
tabs: N.TABS.map(([id, label]) => [id, label, (N.SUBS[id] || []).map(([sub, name]) => [sub, name])]),
|
||||
views: N.VIEWS.map(([id, label]) => [id, label]),
|
||||
@@ -37,10 +37,10 @@ class TestNavModule(unittest.TestCase):
|
||||
stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]]
|
||||
self.assertEqual(self.data["tabs"], [stream, ["agents", "Agents", []], ["artifacts", "Artifacts", []], ["tasks", "Tasks", []]])
|
||||
|
||||
def test_agents_and_artifacts_offer_five_views_in_order_and_tasks_five(self):
|
||||
def test_agents_and_artifacts_offer_five_views_in_order_and_tasks_six(self):
|
||||
shared = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]
|
||||
self.assertEqual((self.data["views"], self.data["artifacts"]), (shared, shared))
|
||||
task_views = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"]]
|
||||
task_views = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"], ["3d", "3D"]]
|
||||
self.assertEqual((self.data["taskViews"], self.data["tasks"]), (task_views,) * 2)
|
||||
|
||||
def test_only_agents_add_the_hierarchy_after_3d(self):
|
||||
@@ -82,6 +82,7 @@ class TestNavModule(unittest.TestCase):
|
||||
{"screen": "tasks", "view": "gantt", "redirect": None},
|
||||
{"screen": "tasks", "view": "burn", "redirect": None},
|
||||
{"screen": "agents", "view": None, "redirect": None},
|
||||
{"screen": "tasks", "view": "3d", "redirect": None},
|
||||
])
|
||||
|
||||
def test_every_screen_has_its_own_hash_and_tab(self):
|
||||
|
||||
+83
-4
@@ -46,8 +46,33 @@ const scope = (choice) => {
|
||||
return {tasks: found.rows.map((entry) => entry.task), from: found.from / H, to: found.to / H};
|
||||
};
|
||||
const series = (estimation) => P.burn(P.scoped(rows, "all", context).rows, {estimation, from: 70 * H, to: now}).points.map((point) => [point.t / H, point.scope, point.done, point.remaining, point.unestimated]);
|
||||
const kept = P.scoped(rows, "all", context).rows;
|
||||
const stepsOf = (from, to) => P.steps(kept, P.burn(kept, {estimation: "none", from, to}).points, from);
|
||||
const eta = (estimation) => Object.fromEntries([...P.etas(rows, edges, {now, estimation}).entries()].map(([task, found]) => [task, [found.start === null ? null : found.start / H, found.eta === null ? null : found.eta / H, found.forecast]]));
|
||||
const D = 86400;
|
||||
const burnt = [
|
||||
row("t/a", {status: "done", ended: true, start: 0, end: 2 * D}),
|
||||
row("t/b", {status: "done", ended: true, start: 0, end: 4 * D}),
|
||||
row("t/c", {status: "done", ended: true, start: 0, end: 6 * D}),
|
||||
row("t/d", {status: "running", start: 9 * D}),
|
||||
row("t/e", {}),
|
||||
];
|
||||
const burnPoints = P.burn(burnt, {estimation: "none", from: 0, to: 10 * D}).points;
|
||||
const rising = P.burn([row("t/x", {}), row("t/y", {status: "running", start: 5 * D})], {estimation: "none", from: 0, to: 10 * D}).points;
|
||||
const trendOf = (points, mode, days) => {
|
||||
const found = P.trend(points, {mode, days});
|
||||
return found && {start: found.start / D, forecast: found.forecast === null ? null : found.forecast / D, meets: found.forecast === null ? null : P.lineAt(found.lines.done, found.forecast) - P.lineAt(found.lines.scope, found.forecast), slope: found.lines.remaining.slope * D};
|
||||
};
|
||||
console.log(JSON.stringify({
|
||||
options: [P.FORECASTS, P.ESTIMATIONS, P.SORTS.map(([id]) => id), P.SERIES],
|
||||
options: [P.FORECASTS, P.ESTIMATIONS, P.TRENDS, P.SORTS.map(([id]) => id), P.SERIES, P.TRENDED],
|
||||
burnt: burnPoints.map((point) => [point.t / D, point.remaining]),
|
||||
trends: {
|
||||
off: trendOf(burnPoints, "off", 7), regression: trendOf(burnPoints, "regression", 7), velocity7: trendOf(burnPoints, "velocity", 7), velocity30: trendOf(burnPoints, "velocity", 30),
|
||||
velocity3: trendOf(burnPoints, "velocity", 3), rising: trendOf(rising, "regression", 7), risingVelocity: trendOf(rising, "velocity", 7), single: trendOf(burnPoints.slice(-1), "regression", 7),
|
||||
beyondDates: 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}).forecast,
|
||||
},
|
||||
steps: {whole: stepsOf(70 * H, now), window: stepsOf(90 * H, 99 * H), burnt: P.steps(burnt, burnPoints, 0)},
|
||||
etas: {none: eta("none"), points: eta("points"), loop: [...P.etas(loop, looped, {now, estimation: "none"}).keys()].sort()},
|
||||
medians: P.medians(rows),
|
||||
median: [P.median([]), P.median([3, 1, 2]), P.median([4, 1, 3, 2])],
|
||||
bars: {noneAlways: lay("none", "always"), pointsAlways: lay("points", "always"), pointsOnly: lay("points", "points"), noneOnly: lay("none", "points"), real: lay("points", "real")},
|
||||
@@ -67,12 +92,66 @@ class TestPlanModule(unittest.TestCase):
|
||||
done = subprocess.run(["node", "-e", PLAN_PROBE, str(PLAN)], capture_output=True, text=True, timeout=60, check=True)
|
||||
cls.data = json.loads(done.stdout)
|
||||
|
||||
def test_the_selectors_offer_the_forecasts_estimations_sort_modes_and_series(self):
|
||||
forecasts, estimations, sorts, series = self.data["options"]
|
||||
def test_the_selectors_offer_the_forecasts_estimations_trends_sort_modes_and_series(self):
|
||||
forecasts, estimations, trends, sorts, series, trended = self.data["options"]
|
||||
self.assertEqual(forecasts, [["always", "always"], ["points", "points only"], ["real", "real only"]])
|
||||
self.assertEqual(estimations, [["none", "No estimate"], ["points", "Story points"]])
|
||||
self.assertEqual(trends, [["off", "off"], ["regression", "linear regression"], ["velocity", "velocity"]])
|
||||
self.assertEqual(sorts, ["prio", "wsjf", "value", "complexity", "status", "start", "end", "agent", "title"])
|
||||
self.assertEqual(series, ["scope", "done", "remaining", "unestimated"])
|
||||
self.assertEqual((series, trended), (["scope", "done", "remaining", "unestimated"], ["scope", "done", "remaining"]))
|
||||
|
||||
def test_the_regression_forecast_is_where_the_least_squares_line_of_remaining_reaches_zero_and_done_meets_scope(self):
|
||||
self.assertEqual(self.data["burnt"], [[0, 4], [2, 3], [4, 2], [6, 1], [9, 2], [10, 2]])
|
||||
found = self.data["trends"]["regression"]
|
||||
self.assertEqual(found["start"], 0)
|
||||
self.assertAlmostEqual(found["slope"], -86 / 461, places=9)
|
||||
self.assertAlmostEqual(found["forecast"], 760 / 43, places=9)
|
||||
self.assertAlmostEqual(found["meets"], 0, places=9)
|
||||
|
||||
def test_the_velocity_forecast_runs_the_mean_rate_of_the_last_n_days_from_the_last_point(self):
|
||||
trends = self.data["trends"]
|
||||
self.assertEqual({key: trends[key]["start"] for key in ("velocity7", "velocity30")}, {"velocity7": 3, "velocity30": 0})
|
||||
self.assertAlmostEqual(trends["velocity7"]["forecast"], 24, places=9)
|
||||
self.assertAlmostEqual(trends["velocity30"]["forecast"], 20, places=9)
|
||||
self.assertAlmostEqual(trends["velocity7"]["slope"], -1 / 7, places=9)
|
||||
self.assertAlmostEqual(trends["velocity7"]["meets"], 0, places=9)
|
||||
|
||||
def test_a_trend_that_never_falls_has_no_forecast_and_off_or_a_single_point_no_trend(self):
|
||||
trends = self.data["trends"]
|
||||
self.assertEqual((trends["velocity3"]["start"], trends["velocity3"]["forecast"]), (7, None))
|
||||
self.assertGreater(trends["velocity3"]["slope"], 0)
|
||||
self.assertEqual((trends["rising"]["forecast"], trends["risingVelocity"]["forecast"]), (None, None))
|
||||
self.assertEqual((trends["off"], trends["single"]), (None, None))
|
||||
|
||||
def test_a_falling_trend_that_reaches_zero_only_after_the_last_date_has_no_forecast(self):
|
||||
self.assertIsNone(self.data["trends"]["beyondDates"])
|
||||
|
||||
def test_each_point_lists_the_tasks_that_started_ended_or_joined_the_scope_at_its_step(self):
|
||||
steps = self.data["steps"]
|
||||
empty = {"started": [], "ended": [], "joined": []}
|
||||
self.assertEqual(steps["burnt"], [
|
||||
{"started": ["t/a", "t/b", "t/c"], "ended": [], "joined": ["t/e"]},
|
||||
{**empty, "ended": ["t/a"]}, {**empty, "ended": ["t/b"]}, {**empty, "ended": ["t/c"]}, {**empty, "started": ["t/d"]}, empty,
|
||||
])
|
||||
self.assertEqual(steps["whole"], [
|
||||
{**empty, "joined": ["work/next", "work/tail"]}, {**empty, "started": ["work/a"]}, {**empty, "ended": ["work/a"]}, {**empty, "started": ["work/b"]},
|
||||
{**empty, "started": ["work/stop"]}, {**empty, "ended": ["work/stop"]}, {**empty, "ended": ["work/b"]}, {**empty, "started": ["work/c"]},
|
||||
{**empty, "ended": ["work/c"]}, {**empty, "started": ["work/run"]}, empty,
|
||||
])
|
||||
self.assertEqual(steps["window"], [
|
||||
{"started": ["work/a", "work/b", "work/c", "work/stop"], "ended": ["work/a", "work/b", "work/stop"], "joined": ["work/next", "work/tail"]},
|
||||
{**empty, "ended": ["work/c"]}, {**empty, "started": ["work/run"]}, empty,
|
||||
])
|
||||
|
||||
def test_the_eta_is_the_end_when_ended_else_start_or_now_behind_the_predecessors_plus_the_forecast_length(self):
|
||||
ended = {"work/a": [80, 82, False], "work/b": [84, 88, False], "work/c": [90, 91, False], "work/stop": [85, 86, False], "operator/push": [None, 70, False]}
|
||||
self.assertEqual(self.data["etas"]["none"], {
|
||||
**ended, "work/run": [95, 97, True], "work/next": [100, 102, True], "work/tail": [102, 104, True], "queue/x": [100, 102, True],
|
||||
})
|
||||
self.assertEqual(self.data["etas"]["points"], {
|
||||
**ended, "work/run": [95, 102.5, True], "work/next": [102.5, 107, True], "work/tail": [107, 109, True], "queue/x": [102.5, 104.5, True],
|
||||
})
|
||||
self.assertEqual(self.data["etas"]["loop"], ["loop/a", "loop/b"])
|
||||
|
||||
def test_medians_take_done_tasks_only_and_the_points_of_those_with_points(self):
|
||||
self.assertEqual(self.data["medians"], {"duration": 2 * HOUR, "perPoint": 1.5 * HOUR})
|
||||
|
||||
@@ -150,6 +150,23 @@ class TestHubSettings(HubCase):
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(json.loads(body)["settings"]["speech"], {"on": False, "classes": ["broadcast", "mayday", "security"], "muted": ["Operator", "name:Docs", "s1"]})
|
||||
|
||||
def test_the_radio_gear_stores_tones_squelch_and_read_aloud_without_a_passkey_and_keeps_every_other_setting(self):
|
||||
current = self.snapshot()["settings"]
|
||||
gear = {
|
||||
**current, "sounds": {**current["sounds"], "radio": {"on": False, "volume": 0.4}}, "squelch": 0.35,
|
||||
"speech": {"on": True, "classes": ["post", "mayday"], "muted": ["s1"]},
|
||||
}
|
||||
self.assertIsNone(self.prepared(gear)["payload"])
|
||||
status, _, body = self.post_json("/api/settings", {**gear, "proof": None}, self.guard(self.host))
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(json.loads(body)["settings"], gear)
|
||||
self.assertEqual(settings.Settings(self.state / "settings.json").get(), gear)
|
||||
self.assertEqual(self.snapshot()["settings"], gear)
|
||||
for broken in ({**gear, "squelch": 1.5}, {**gear, "sounds": {**gear["sounds"], "radio": {"on": "no", "volume": 0.4}}}, {**gear, "speech": {**gear["speech"], "classes": ["post"]}}):
|
||||
with self.subTest(broken=broken):
|
||||
self.assertEqual(self.post_json("/api/settings", broken, self.guard(self.host))[0], 400)
|
||||
self.assertEqual(self.snapshot()["settings"], gear)
|
||||
|
||||
def test_the_estimation_mode_defaults_to_no_estimate_is_stored_and_survives_a_reload(self):
|
||||
self.assertEqual((settings.SETTINGS["estimation"], self.snapshot()["settings"]["estimation"]), ("none", "none"))
|
||||
good = self.guard(self.host)
|
||||
@@ -166,6 +183,29 @@ class TestHubSettings(HubCase):
|
||||
(self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8")
|
||||
self.assertEqual(settings.Settings(self.state / "settings.json").get()["estimation"], "none")
|
||||
|
||||
def test_the_trend_defaults_to_off_over_seven_days_is_validated_stored_and_survives_a_reload(self):
|
||||
self.assertEqual((settings.SETTINGS["trend"], self.snapshot()["settings"]["trend"]), ({"mode": "off", "days": 7}, {"mode": "off", "days": 7}))
|
||||
good = self.guard(self.host)
|
||||
wrongs = [{"mode": mode, "days": 7} for mode in ("linear", "Regression", "", None, 1)]
|
||||
wrongs += [{"mode": "velocity", "days": days} for days in (0, 366, 1.5, True, "7", None, -3)]
|
||||
wrongs += [{"mode": "velocity"}, {"days": 7}, {"mode": "velocity", "days": 7, "extra": 1}, "velocity", ["velocity", 7]]
|
||||
for wrong in wrongs:
|
||||
with self.subTest(wrong=wrong):
|
||||
self.assertEqual(self.post_json("/api/settings", {**settings.SETTINGS, "trend": wrong}, good)[0], 400)
|
||||
self.assertEqual(self.post_json("/api/settings", {key: value for key, value in settings.SETTINGS.items() if key != "trend"}, good)[0], 400)
|
||||
self.assertEqual(self.snapshot()["settings"]["trend"], {"mode": "off", "days": 7})
|
||||
for chosen in ({"mode": "regression", "days": 7}, {"mode": "velocity", "days": 1}, {"mode": "velocity", "days": 365}, {"mode": "velocity", "days": 14}):
|
||||
with self.subTest(chosen=chosen):
|
||||
self.assertIsNone(self.prepared({**self.snapshot()["settings"], "trend": chosen})["payload"])
|
||||
status, _, body = self.post_json("/api/settings", {**self.snapshot()["settings"], "trend": chosen, "proof": None}, good)
|
||||
self.assertEqual((status, json.loads(body)["settings"]["trend"]), (200, chosen))
|
||||
self.assertEqual(json.loads((self.state / "settings.json").read_text(encoding="utf-8"))["trend"], {"mode": "velocity", "days": 14})
|
||||
self.assertEqual(settings.Settings(self.state / "settings.json").get()["trend"], {"mode": "velocity", "days": 14})
|
||||
self.assertEqual(self.snapshot()["settings"]["trend"], {"mode": "velocity", "days": 14})
|
||||
older = {key: value for key, value in self.settings_payload(STREAMS).items() if key != "trend"}
|
||||
(self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8")
|
||||
self.assertEqual(settings.Settings(self.state / "settings.json").get()["trend"], {"mode": "off", "days": 7})
|
||||
|
||||
def test_settings_written_before_the_radio_filter_keep_their_values(self):
|
||||
older = {key: value for key, value in self.settings_payload(STREAMS).items() if key != "speech"}
|
||||
(self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8")
|
||||
|
||||
Reference in new issue
Block a user