feat(orchestrator)!: S0 English everywhere, Sécurité, NATO hashes, ICAO digits
Requirement 001 phase 6, S0: - Speech: the security class shows as "Sécurité" and is spoken as in maritime radio, "say-cure-ee-tay" three times. A hex run of 7 or more characters with a letter and a digit (commit, session, message ids) is spelled in the NATO alphabet; every other digit is spoken on its own in ICAO pronunciation (42305: four two tree zero fife). The spelling runs inside clip() before the length cut, so a phrase still fits SPEECH_LIMIT; the displayed text keeps its digits and hashes. - English: every orchestrator label, tooltip, message, error, script output, document and test string is English (Regeln -> Firewall, Hierarchie -> Hierarchy, Steuerung -> Controls, Ausführen -> Run, ...); the page declares lang="en" and formats dates as en-GB. Task filter ids are English. German stays only in the skill trigger phrases that quote the operator, and as identifiers: the view keys (view=) and the leitstand id. - tests/test_english.py scans every tracked text file for umlauts and German words, with an allowlist of the quoted trigger phrases. BREAKING CHANGE: the Stream search tokens art: and seit: are now kind: and since:; an old art: or seit: token counts as a plain word. Evidence: make test 657 OK, ruff and markdownlint clean (/tmp/s0-make-test-1.log); e2e 17 OK, none skipped (/tmp/s0-e2e-1.log); MEDUSA 86 files, 24 findings on reworded strings accepted after an independent skeptic (21 path:line, 0 rejected, no behaviour change in signing, approving, rules, denying, offices, events), rescan 0 open. 🎯 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
a388c6ef77
commit
c035373518
86 files changed
+1027
-930
No files matched your search
+124
-124
@@ -36,7 +36,7 @@ WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||||
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
||||
TASK_VIEWS = ("list", "board", "rasci")
|
||||
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", "Regeln", "Radio"]
|
||||
SUBS = ["Inbox", "Firewall", "Radio"]
|
||||
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
|
||||
HELPERS = """
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -56,7 +56,7 @@ HELPERS = """
|
||||
const name = (node) => (node ? node.getAttribute("aria-label") || text(node) : null);
|
||||
"""
|
||||
PROBE = """(async () => {@HELPERS@
|
||||
await until(() => !document.querySelector("#main .space-note") || !text(document.querySelector("#main .space-note")).startsWith("Lade"), 8000);
|
||||
await until(() => !document.querySelector("#main .space-note") || !text(document.querySelector("#main .space-note")).startsWith("Loading"), 8000);
|
||||
const badge = (button) => {
|
||||
const found = button.querySelector(".badge");
|
||||
return found && !found.hidden ? text(found) : null;
|
||||
@@ -73,9 +73,9 @@ PROBE = """(async () => {@HELPERS@
|
||||
badges: Object.fromEntries(all("#tabs button").map((button) => [button.id, badge(button)])),
|
||||
banner: banner.hidden ? null : text(banner),
|
||||
rail: Boolean(document.getElementById("rail")),
|
||||
views: all('#main [aria-label="Ansicht"] button').map(name),
|
||||
layouts: all('#main [aria-label="Anordnung"] button').map(name),
|
||||
chips: all('#main [aria-label="Kategorien"] button').map(name),
|
||||
views: all('#main [aria-label="View"] button').map(name),
|
||||
layouts: all('#main [aria-label="Layout"] button').map(name),
|
||||
chips: all('#main [aria-label="Categories"] button').map(name),
|
||||
menus: all("#main details.menu > summary").map(name),
|
||||
time: text(document.querySelector("#time > summary")),
|
||||
replay: Boolean(document.querySelector("#time .replay input[type=range]")),
|
||||
@@ -87,7 +87,7 @@ PROBE = """(async () => {@HELPERS@
|
||||
unlabelled: icons.map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).map((owner) => owner.outerHTML.slice(0, 160)),
|
||||
broken: all("svg.bi use").filter((use) => !document.getElementById(use.getAttribute("href").slice(1))).map((use) => use.getAttribute("href")),
|
||||
states: all("#main span.state").map(name),
|
||||
deletes: all('#main button[aria-label="Löschen"], #main button[aria-label="Alle gemergten löschen"]').map((button) => [name(button), button.disabled, button.title]),
|
||||
deletes: all('#main button[aria-label="Delete"], #main button[aria-label="Delete all merged"]').map((button) => [name(button), button.disabled, button.title]),
|
||||
spawn: all("#main .spawn-tree li").map((item) => [item.dataset.kind, item.dataset.running || null]),
|
||||
marks: all("#main svg.chart .node").length,
|
||||
note: text(document.querySelector("#main .space-note")),
|
||||
@@ -95,7 +95,7 @@ PROBE = """(async () => {@HELPERS@
|
||||
org: all("#main .org-chart .org-row[data-office]").map((row) => [row.dataset.office, text(row.querySelector(".org-holder"))]),
|
||||
toggles: all("#main .org-toggle").map((button) => [name(button), button.getAttribute("aria-expanded")]),
|
||||
order: all("#main .agent-order li").map((item) => item.dataset.name),
|
||||
taskFilters: all('#main [aria-label="Aufgabenfilter"] button').map((button) => [button.dataset.filter, button.getAttribute("aria-pressed"), text(button.querySelector(".count"))]),
|
||||
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)]),
|
||||
matrix: [all("#main table.matrix thead th").length, all("#main table.matrix tbody tr").length, all("#main table.matrix td .letter").map(text).join("")],
|
||||
@@ -111,7 +111,7 @@ ORG_OPEN = """(async () => {@HELPERS@
|
||||
if (!row) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const toggle = all("#main .org-toggle").find((button) => name(button) === "Zuklappen: identity");
|
||||
const toggle = all("#main .org-toggle").find((button) => name(button) === "Collapse: identity");
|
||||
toggle.focus();
|
||||
return {focused: document.activeElement.dataset.focus, offices: all("#main .org-row[data-office]").map((entry) => entry.dataset.office)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
@@ -127,19 +127,19 @@ ORG_FOLDED = """(async () => {@HELPERS@
|
||||
ORDER_MOVED = """(async () => {@HELPERS@
|
||||
const after = all("#main .agent-order li").map((item) => item.dataset.name);
|
||||
const focused = document.activeElement.dataset.focus || null;
|
||||
const save = all("#main .agent-order button").find((button) => text(button) === "Reihenfolge speichern");
|
||||
const save = all("#main .agent-order button").find((button) => text(button) === "Save order");
|
||||
const disabled = save.disabled;
|
||||
save.click();
|
||||
const toast = await until(() => all("#toasts .toast").find((box) => text(box).startsWith("Priorität")), 8000);
|
||||
const toast = await until(() => all("#toasts .toast").find((box) => text(box).startsWith("Priority")), 8000);
|
||||
return {after, focused, disabled, toast: text(toast)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
OPEN_TAKEOVER = """(async () => {@HELPERS@
|
||||
document.querySelector('#main [data-focus="takeover:identity/oidc"]').click();
|
||||
await sleep(400);
|
||||
return {title: text(document.querySelector(".sheet-head .panel-title")), options: all(".sheet-box select option").map((option) => option.value), work: document.querySelector('.sheet-box input[aria-label="Arbeit"]').value, focused: document.activeElement.tagName};
|
||||
return {title: text(document.querySelector(".sheet-head .panel-title")), options: all(".sheet-box select option").map((option) => option.value), work: document.querySelector('.sheet-box input[aria-label="Work"]').value, focused: document.activeElement.tagName};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
CONFIRM_TAKEOVER = """(async () => {@HELPERS@
|
||||
all(".sheet-box button").find((button) => name(button) === "Übergeben").click();
|
||||
all(".sheet-box button").find((button) => name(button) === "Hand over").click();
|
||||
const done = await until(() => document.querySelector(".sheet-box [data-result]"), 30000);
|
||||
return {result: done ? done.dataset.result : null, text: text(done), toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
@@ -152,11 +152,11 @@ AFTER_TAKEOVER = """(async () => {@HELPERS@
|
||||
return {holder: text(row)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
INBOX_CHIP = """(async () => {@HELPERS@
|
||||
const chip = (id) => document.querySelector(`#main [aria-label="Aufgabenfilter"] button[data-filter="${id}"]`);
|
||||
const chip = (id) => document.querySelector(`#main [aria-label="Task filters"] button[data-filter="${id}"]`);
|
||||
chip("operator").click();
|
||||
await until(() => location.hash === "#stream/inbox" && document.querySelector("#main .inbox-list"), 4000);
|
||||
const inbox = {hash: location.hash, items: all("#main .inbox-list > li").length, counted: Number(text(chip("operator").querySelector(".count"))), pressed: chip("operator").getAttribute("aria-pressed"), current: all("#tabs button[aria-current=page]").map(name)};
|
||||
chip("freigabe").click();
|
||||
chip("grant").click();
|
||||
await until(() => location.hash.startsWith("#tasks/"), 4000);
|
||||
await sleep(300);
|
||||
return {inbox, back: {hash: location.hash, rows: all("#main .task-row").map((row) => [row.dataset.task, text(row.querySelector(".pill"))])}};
|
||||
@@ -166,13 +166,13 @@ PIN = """(async () => {@HELPERS@
|
||||
if (!row) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
row.querySelector('button[aria-label="Priorität"]').click();
|
||||
row.querySelector('button[aria-label="Priority"]').click();
|
||||
await sleep(400);
|
||||
const field = document.querySelector('.sheet-box input[aria-label="Priorität"]');
|
||||
const field = document.querySelector('.sheet-box input[aria-label="Priority"]');
|
||||
field.value = "2";
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
const preview = all(".sheet-box .task-edit .command code").map(text);
|
||||
all(".sheet-box button").find((button) => name(button) === "Anheften").click();
|
||||
all(".sheet-box button").find((button) => name(button) === "Pin").click();
|
||||
const done = await until(() => document.querySelector(".sheet-box [data-jobs]"), 20000);
|
||||
return {title: text(document.querySelector(".sheet-head .panel-title")), preview, jobs: done ? done.dataset.jobs : null, toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
@@ -186,7 +186,7 @@ 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="Klassen"] input').map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
|
||||
found.speech = all('.sheet-box [aria-label="Classes"] input').map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
|
||||
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();
|
||||
@@ -194,9 +194,9 @@ ACTIONS = """(async () => {@HELPERS@
|
||||
found.palette = all(".palette-list li > span:first-child").map(text);
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||||
await sleep(200);
|
||||
document.querySelector('#main [aria-label="Kategorien"] button:nth-child(2)').click();
|
||||
document.querySelector('#main [aria-label="Categories"] button:nth-child(2)').click();
|
||||
await sleep(900);
|
||||
document.querySelector('#main button.linkish[title$="öffnen"]').click();
|
||||
document.querySelector('#main button.linkish[title^="Open "]').click();
|
||||
await sleep(500);
|
||||
found.drawer = all("#drawer input[type=radio]").map((input) => [input.value, input.checked]);
|
||||
found.drawerUnlabelled = all("#drawer svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length;
|
||||
@@ -217,8 +217,8 @@ PICK_3D = """(async () => {@HELPERS@
|
||||
return {panel: text(document.querySelector("#main aside .panel-title")), label: chip.textContent};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
CALL = """(async () => {@HELPERS@
|
||||
document.querySelector('#main [aria-label="Kategorien"] button[aria-label="Funk"]').click();
|
||||
const someone = await until(() => document.querySelector('#main button.linkish[title$="öffnen"]'), 8000);
|
||||
document.querySelector('#main [aria-label="Categories"] button[aria-label="Radio"]').click();
|
||||
const someone = await until(() => document.querySelector('#main button.linkish[title^="Open "]'), 8000);
|
||||
someone.click();
|
||||
await sleep(500);
|
||||
document.querySelector('#drawer input[type=radio][value="call"]').click();
|
||||
@@ -226,7 +226,7 @@ CALL = """(async () => {@HELPERS@
|
||||
field.value = "E2E call ping";
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
document.querySelector('#drawer button[type="submit"]').click();
|
||||
const entry = await until(() => all("#main .entry").find((row) => row.textContent.includes("Gelesen") && row.textContent.includes("E2E call ping")), 20000);
|
||||
const entry = await until(() => all("#main .entry").find((row) => row.textContent.includes("Read") && row.textContent.includes("E2E call ping")), 20000);
|
||||
return {to: text(someone), receipt: entry ? text(entry) : null, sent: all("#main .entry").filter((row) => row.textContent.includes("E2E call ping")).length};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
OPEN_DELETE = """(async () => {@HELPERS@
|
||||
@@ -236,22 +236,22 @@ OPEN_DELETE = """(async () => {@HELPERS@
|
||||
}
|
||||
card.open = true;
|
||||
await sleep(300);
|
||||
const every = document.querySelector('#main button[aria-label="Alle gemergten löschen"]');
|
||||
const buttons = all('button[aria-label="Löschen"]', card);
|
||||
const every = document.querySelector('#main button[aria-label="Delete all merged"]');
|
||||
const buttons = all('button[aria-label="Delete"]', card);
|
||||
const states = all("span.state", card).map(name);
|
||||
buttons.find((button) => !button.disabled).click();
|
||||
await sleep(500);
|
||||
return {states, every: [every.disabled, every.title], buttons: buttons.map((button) => [button.disabled, button.title]), title: text(document.querySelector(".sheet-head .panel-title")), dry: all('.sheet-box [aria-label="Trockenlauf"] pre').map(text)};
|
||||
return {states, every: [every.disabled, every.title], buttons: buttons.map((button) => [button.disabled, button.title]), title: text(document.querySelector(".sheet-head .panel-title")), dry: all('.sheet-box [aria-label="Dry run"] pre').map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
CONFIRM = """(async () => {@HELPERS@
|
||||
all(".sheet-box button").find((button) => name(button) === "Löschen").click();
|
||||
all(".sheet-box button").find((button) => name(button) === "Delete").click();
|
||||
const done = await until(() => document.querySelector(".sheet-box [data-result]"), 30000);
|
||||
const job = document.querySelector(".sheet-box [data-cleanup]");
|
||||
return {job: job ? job.dataset.cleanup : null, result: done ? done.dataset.result : null, text: text(done), toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
AFTER = """(async () => {@HELPERS@
|
||||
document.querySelector(".sheet-head button").click();
|
||||
const past = await until(() => all("#main details.repo").find((details) => details.textContent.includes("Zuletzt gelöscht")), 15000);
|
||||
const past = await until(() => all("#main details.repo").find((details) => details.textContent.includes("Recently deleted")), 15000);
|
||||
return {past: past ? [past.querySelector("[data-result]").dataset.result, name(past.querySelector("span.state"))] : null, branches: all("#main details.repo").filter((details) => details !== past).flatMap((details) => all(".branch-name strong", details)).map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
OVERLAP = """
|
||||
@@ -291,12 +291,12 @@ RULES_OVERLAP = """(async () => {@HELPERS@@CLASHES@
|
||||
SETTINGS_OVERLAP = """(async () => {@HELPERS@@CLASHES@
|
||||
document.getElementById("settings-open").click();
|
||||
const settings = await until(() => document.querySelector(".sheet-box form.settings"), 8000);
|
||||
all("button", settings).find((button) => text(button) === "+ Sender").click();
|
||||
all("button", settings).find((button) => text(button) === "+ Station").click();
|
||||
const theme = all("select", settings).find((select) => [...select.options].some((option) => option.value === "custom"));
|
||||
theme.value = "custom";
|
||||
theme.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
await sleep(250);
|
||||
const place = all("section", settings).find((section) => text(section.querySelector("h3")) === "Ort");
|
||||
const place = all("section", settings).find((section) => text(section.querySelector("h3")) === "Location");
|
||||
place.scrollIntoView({block: "start"});
|
||||
return clashes(settings);
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
|
||||
@@ -334,7 +334,7 @@ ADD_STATION = """(async () => {@HELPERS@
|
||||
const clock = (date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
document.getElementById("settings-open").click();
|
||||
const form = await until(() => document.querySelector(".sheet-box form.settings"), 8000);
|
||||
all("button", form).find((button) => text(button) === "+ Sender").click();
|
||||
all("button", form).find((button) => text(button) === "+ Station").click();
|
||||
await sleep(200);
|
||||
const row = all(".stream-row", form).pop();
|
||||
const [label, address, start, end] = all("input.field", row);
|
||||
@@ -350,10 +350,10 @@ ADD_STATION = """(async () => {@HELPERS@
|
||||
box.checked = true;
|
||||
});
|
||||
all("button", form).find((button) => button.type === "submit").click();
|
||||
await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Neuer Sender")), 8000);
|
||||
await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("New station")), 8000);
|
||||
return {toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
STATION_NAME = "E2E Ton mit einem sehr langen Sendernamen, der in keine Zeile passt"
|
||||
STATION_NAME = "E2E tone with a very long station name that fits on no single line"
|
||||
DLF_PATH = "/dlf/01/high/aac/stream.aac?aggregator=web"
|
||||
DLF_NODE = "/ard/dlf/01/aac/192/stream.aac?aggregator=web"
|
||||
RADIO_STATE = """(async () => {@HELPERS@@MEDIA@
|
||||
@@ -459,22 +459,22 @@ COMPOSE = """(async () => {@HELPERS@
|
||||
COMPOSE_SEND = """(async () => {@HELPERS@
|
||||
const form = document.querySelector("#main form.composer");
|
||||
const field = form.querySelector("textarea");
|
||||
field.value = `${field.value} bitte`;
|
||||
field.value = `${field.value} please`;
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
form.querySelector('.voice-note input[type="checkbox"]').checked = @ATTACH@;
|
||||
const before = all("#toasts .toast").length;
|
||||
form.querySelector('button[type="submit"]').click();
|
||||
const sent = await until(() => all("#toasts .toast").slice(before).map(text).find((toast) => toast.startsWith("Call an")), 20000);
|
||||
const sent = await until(() => all("#toasts .toast").slice(before).map(text).find((toast) => toast.startsWith("Call to")), 20000);
|
||||
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) === "Mayday aufheben" && !node.disabled), 8000);
|
||||
const button = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Cancel mayday" && !node.disabled), 8000);
|
||||
if (!button) {
|
||||
return {error: "no enabled Mayday aufheben button"};
|
||||
return {error: "no enabled Cancel mayday button"};
|
||||
}
|
||||
button.click();
|
||||
const done = await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Mayday aufheben")), 15000);
|
||||
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)
|
||||
|
||||
@@ -514,20 +514,20 @@ LABELS = """
|
||||
const line = () => text(document.querySelector("#main .console .console-status"));
|
||||
"""
|
||||
RUN_START = """(async () => {@HELPERS@@LABELS@
|
||||
const row = await until(() => rowOf("Zeile"), 8000);
|
||||
const row = await until(() => rowOf("Row"), 8000);
|
||||
if (!row) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
press(row, "Ausführen");
|
||||
const toast = await until(() => all("#toasts .toast.wait").find((box) => box.textContent.includes("Ausführen")), 8000);
|
||||
press(row, "Run");
|
||||
const toast = await until(() => all("#toasts .toast.wait").find((box) => box.textContent.includes("Run")), 8000);
|
||||
const queued = await until(() => out() && line(), 15000);
|
||||
await until(() => out().textContent.includes("Zeile 60"), 20000);
|
||||
await until(() => out().textContent.includes("Row 60"), 20000);
|
||||
return {toast: text(toast), queued, running: line(), overflows: out().scrollHeight > out().clientHeight, followed: atEnd()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
RUN_HELD = """(async () => {@HELPERS@@LABELS@
|
||||
out().scrollTop = 0;
|
||||
await sleep(300);
|
||||
await until(() => out().textContent.includes("Zeile 80"), 20000);
|
||||
await until(() => out().textContent.includes("Row 80"), 20000);
|
||||
await sleep(300);
|
||||
return {held: out().scrollTop, running: line()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
@@ -539,8 +539,8 @@ RUN_DONE = """(async () => {@HELPERS@@LABELS@
|
||||
return {done: line(), last: lines[lines.length - 1], lines: lines.length, followed: atEnd(), actions: all(".item-actions button", out().closest("li")).map(name)};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
LAYOUT = """(async () => {@HELPERS@@LABELS@
|
||||
await until(() => document.querySelector("#main .console") && rowOf("Regelwunsch"), 8000);
|
||||
const rule = rowOf("Regelwunsch");
|
||||
await until(() => document.querySelector("#main .console") && rowOf("Rule request"), 8000);
|
||||
const rule = rowOf("Rule request");
|
||||
return {
|
||||
kinds: all("#main .inbox-list > li").map((li) => text(li.querySelector(".pill"))),
|
||||
splits: all("#main .deny-split").map((group) => all("button", group).map(name)),
|
||||
@@ -549,14 +549,14 @@ LAYOUT = """(async () => {@HELPERS@@LABELS@
|
||||
fits: out().getBoundingClientRect().right <= window.innerWidth,
|
||||
dry: text(rule.querySelector(".dry-run-count")),
|
||||
fields: all(".rule-request dt", rule).map(text),
|
||||
accept: all("button", rule).filter((button) => name(button) === "Annehmen").length,
|
||||
accept: all("button", rule).filter((button) => name(button) === "Accept").length,
|
||||
unlabelled: unlabelled(document),
|
||||
wide: wide(),
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
DENY_SHEET = """(async () => {@HELPERS@@LABELS@
|
||||
const row = await until(() => rowOf("git push"), 8000);
|
||||
press(row, "Ablehnen über …");
|
||||
press(row, "Deny via …");
|
||||
const box = await until(() => document.querySelector(".sheet-box"), 4000);
|
||||
return {
|
||||
title: text(box.querySelector(".panel-title")),
|
||||
@@ -569,24 +569,24 @@ DENY_SHEET = """(async () => {@HELPERS@@LABELS@
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
DENY = """(async () => {@HELPERS@@LABELS@
|
||||
const row = await until(() => rowOf("Bash: make deploy"), 8000);
|
||||
press(row, "Ablehnen über …");
|
||||
press(row, "Deny via …");
|
||||
const box = await until(() => document.querySelector(".sheet-box"), 4000);
|
||||
box.querySelector('input[value="call"]').click();
|
||||
box.querySelector("textarea").value = "erst das Gate grün machen";
|
||||
press(box.querySelector(".actions"), "Ablehnen");
|
||||
const undo = await until(() => all("#toasts .toast.wait").find((toast) => toast.textContent.includes("Ablehnung")), 8000);
|
||||
box.querySelector("textarea").value = "make the gate green first";
|
||||
press(box.querySelector(".actions"), "Deny");
|
||||
const undo = await until(() => all("#toasts .toast.wait").find((toast) => toast.textContent.includes("Denial")), 8000);
|
||||
return {undo: text(undo), sheet: !document.getElementById("sheet").hidden};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
DENIED = """(async () => {@HELPERS@@LABELS@
|
||||
return {gone: Boolean(await until(() => !rowOf("Bash: make deploy"), 20000))};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
ACCEPT = """(async () => {@HELPERS@@LABELS@
|
||||
press(rowOf("Regelwunsch"), "Annehmen");
|
||||
const toast = await until(() => all("#toasts .toast").find((box) => box.textContent.includes("Angelegt: Regel")), 15000);
|
||||
await until(() => !rowOf("Regelwunsch"), 8000);
|
||||
return {toast: text(toast), left: Boolean(rowOf("Regelwunsch"))};
|
||||
press(rowOf("Rule request"), "Accept");
|
||||
const toast = await until(() => all("#toasts .toast").find((box) => box.textContent.includes("Created: Rule")), 15000);
|
||||
await until(() => !rowOf("Rule request"), 8000);
|
||||
return {toast: text(toast), left: Boolean(rowOf("Rule request"))};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
SLOW = "seq -f 'Zeile %g' 1 60; sleep 4; seq -f 'Zeile %g' 61 80; sleep 4; echo fertig"
|
||||
SLOW = "seq -f 'Row %g' 1 60; sleep 4; seq -f 'Row %g' 61 80; sleep 4; echo finished"
|
||||
|
||||
|
||||
def labelled(probe: dict) -> dict:
|
||||
@@ -609,12 +609,12 @@ class TestOrchestratorE2E(HubCase):
|
||||
"rasci.json": {
|
||||
"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}},
|
||||
"work/feature": {
|
||||
"title": "Merge-Flow im Hub zeigen", "status": "running", "worker": "Skills session", "objective": "Der Operator sieht, wohin ein Branch fließt", "workdir": feature, "prio": 1,
|
||||
"roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "Flow im Tree", "met": True}, {"text": "Flow in Tasks", "met": False}],
|
||||
"title": "Show the merge flow in the hub", "status": "running", "worker": "Skills session", "objective": "The operator sees where a branch flows", "workdir": feature, "prio": 1,
|
||||
"roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "Flow in the tree", "met": True}, {"text": "Flow in Tasks", "met": False}],
|
||||
"estimates": {"Skills": {"value": 8, "complexity": 3, "at": iso(now - 600)}},
|
||||
},
|
||||
"work/docs": {"title": "README nachziehen", "status": "blocked", "worker": "Docs", "branch": "main", "roles": {"Docs": "R", "Maintainer": "A", "Skills": "S"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
|
||||
"work/old": {"title": "Alte Linie", "status": "done", "worker": "Stage", "end": iso(now - 86400), "roles": {"Stage": "RA"}},
|
||||
"work/docs": {"title": "Update the README", "status": "blocked", "worker": "Docs", "branch": "main", "roles": {"Docs": "R", "Maintainer": "A", "Skills": "S"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
|
||||
"work/old": {"title": "Old line", "status": "done", "worker": "Stage", "end": iso(now - 86400), "roles": {"Stage": "RA"}},
|
||||
"operator/push": {"title": "git push", "workdir": "/repo", "status": "done", "roles": {"Operator": "R"}},
|
||||
},
|
||||
"queue.json": {"seq": 3, "entries": [{"id": "gate-3", "kind": "gate", "task": "work/feature", "workdir": feature, "session": "s1", "name": "Skills", "status": "waiting", "enqueued": now - 120, "cmd": "make test"}]},
|
||||
@@ -679,7 +679,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
def synthesize(body: bytes) -> bytes:
|
||||
word = json.loads(body)["text"]
|
||||
if word in cues:
|
||||
row = {"ts": time.time(), "from": "Stage", "to": [], "text": f"Platte fast voll ({word})\nzweite Zeile", "class": "security", "v": 2}
|
||||
row = {"ts": time.time(), "from": "Stage", "to": [], "text": f"Disk almost full ({word})\nsecond line", "class": "security", "v": 2}
|
||||
with (self.council / "news.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write(self.jsonl([row]))
|
||||
return spoken
|
||||
@@ -731,7 +731,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
steps.append({"width": width, "height": height, "url": None, "wait": 0, "probe": SETTINGS_OVERLAP, "shot": str(OUT / f"{browser}-{width}-settings-form.png")})
|
||||
data = self.drive(steps, None, driver, binary)
|
||||
(OUT / f"{browser}-overlap.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
hidden = {"decision": ("Werkzeug (Glob)", "Verzeichnis (Glob)"), "review": ("Werkzeug (Glob)",), "ask": ()}
|
||||
hidden = {"decision": ("Tool (glob)", "Directory (glob)"), "review": ("Tool (glob)",), "ask": ()}
|
||||
for index, (width, _) in enumerate(WIDTHS):
|
||||
rules, settings = data["results"][2 * index : 2 * index + 2]
|
||||
with self.subTest(browser=browser, width=width):
|
||||
@@ -740,7 +740,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
for kind, found in rules.items():
|
||||
self.assertEqual((found["found"], found["outside"]), ([], []), kind)
|
||||
self.assertEqual([label for label in hidden[kind] if label in found["labels"]], [], kind)
|
||||
self.assertIn("Verzeichnis (Glob)", rules["review"]["labels"])
|
||||
self.assertIn("Directory (glob)", rules["review"]["labels"])
|
||||
self.assertEqual(settings, {"found": [], "outside": []})
|
||||
|
||||
def check_radio(self, browser: str, driver: str, binary: str, redirected: bool = False) -> dict:
|
||||
@@ -770,7 +770,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
(OUT / f"{browser}-radio.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
saved, view, click = data["results"]
|
||||
if signs:
|
||||
self.assertTrue(any(toast.startswith("Neuer Sender") for toast in saved["toasts"]), saved)
|
||||
self.assertTrue(any(toast.startswith("New station") for toast in saved["toasts"]), saved)
|
||||
self.assertNotIn("error", view)
|
||||
self.assertEqual(view["title"], STATION_NAME)
|
||||
self.assertEqual(view["line"], ["nowrap", "ellipsis", True, True])
|
||||
@@ -778,8 +778,8 @@ class TestOrchestratorE2E(HubCase):
|
||||
if not view["media"]["playing"]:
|
||||
self.assertTrue(blocked, view)
|
||||
self.assertEqual(view["state"], "blocked", view)
|
||||
self.assertIn("Klick", view["note"])
|
||||
self.assertTrue(any("blockiert" in toast for toast in view["toasts"]), view["toasts"])
|
||||
self.assertIn("Click", view["note"])
|
||||
self.assertTrue(any("blocked" in toast for toast in view["toasts"]), view["toasts"])
|
||||
self.assertTrue(click["clicked"], click)
|
||||
self.assertNotIn("error", click)
|
||||
self.assertTrue(click["media"]["playing"], click)
|
||||
@@ -815,7 +815,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "branch:"), "shot": self.shot("1366-artifacts-3d-picked")})
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#agents/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "s2"), "shot": self.shot("1366-agents-3d-picked")})
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PROBE, "shot": self.shot("1366-artifacts-3d-reduced-motion"), "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]})
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-freigabe")})
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-grant")})
|
||||
data = self.drive(steps, None)
|
||||
(OUT / "chromium-walk.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual(data["errors"], [])
|
||||
@@ -836,43 +836,43 @@ class TestOrchestratorE2E(HubCase):
|
||||
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"]
|
||||
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["Für dich", "Funk", "Arbeit", "Transkripte"]] * len(WIDTHS))
|
||||
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["For you", "Radio", "Work", "Transcripts"]] * len(WIDTHS))
|
||||
self.assertEqual((stream["menus"], stream["custom"]), (["Filter"], True))
|
||||
self.assertEqual(by_hash["#stream/inbox"]["inbox"], 2)
|
||||
for tab, own in (("agents", ["Hierarchie"]), ("artifacts", [])):
|
||||
for tab, own in (("agents", ["Hierarchy"]), ("artifacts", [])):
|
||||
with self.subTest(tab=tab):
|
||||
self.assertEqual(by_hash[f"#{tab}/orbit"]["views"], ["Orbit", "Chord", "Swimlane", "Tree", "3D", *own])
|
||||
self.assertEqual(by_hash[f"#{tab}/3d"]["layouts"], ["Kraft", "Zeit", "Spawn-Tiefe"])
|
||||
self.assertEqual(by_hash[f"#{tab}/3d"]["layouts"], ["Force", "Time", "Spawn depth"])
|
||||
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
|
||||
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ Knoten · \d+ Kanten · Kraft$")
|
||||
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ nodes · \d+ edges · Force$")
|
||||
agents = by_hash["#agents/orbit"]
|
||||
self.assertEqual(agents["toolbar"], ["Funk an alle", "Mayday aufheben", "Priorität", "Tag/Nacht"])
|
||||
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]])
|
||||
hierarchy = by_hash["#agents/hierarchy"]
|
||||
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
|
||||
self.assertEqual(hierarchy["toggles"], [["Zuklappen: Operator", "true"], ["Zuklappen: Chair", "true"], ["Zuklappen: identity", "true"], ["Zuklappen: Präsidenten", "true"]])
|
||||
self.assertEqual(hierarchy["toggles"], [["Collapse: Operator", "true"], ["Collapse: Chair", "true"], ["Collapse: identity", "true"], ["Collapse: Presidents", "true"]])
|
||||
self.assertEqual(hierarchy["toolbar"], agents["toolbar"])
|
||||
self.assertGreaterEqual(len(hierarchy["order"]), 2)
|
||||
self.assertEqual(by_hash["#agents/tree"]["spawn"][0], ["operator", None])
|
||||
self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"])
|
||||
self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"])
|
||||
artifacts = by_hash["#artifacts/tree"]
|
||||
self.assertEqual(artifacts["deletes"], [["Alle gemergten löschen", True, "Alle gemergten löschen gesperrt: Secret Service locked"], ["Löschen", True, "Löschen gesperrt: Secret Service locked"]])
|
||||
for state in ("gemergt durch #12 in main", "Upstream weg", "geschützt", "1 Commits ungepusht", "sauber", "nicht committet: 0 gestaged, 0 geändert, 1 neu", "gc überspringt: ignored files beyond known caches: notes.txt"):
|
||||
self.assertEqual(artifacts["deletes"], [["Delete all merged", True, "Delete all merged blocked: Secret Service locked"], ["Delete", True, "Delete blocked: Secret Service locked"]])
|
||||
for state in ("merged by #12 into main", "upstream gone", "protected", "1 commits unpushed", "clean", "not committed: 0 staged, 0 changed, 1 new", "gc skips: ignored files beyond known caches: notes.txt"):
|
||||
with self.subTest(state=state):
|
||||
self.assertIn(state, artifacts["states"])
|
||||
for view in ("orbit", "chord"):
|
||||
with self.subTest(view=view):
|
||||
self.assertGreaterEqual(by_hash[f"#artifacts/{view}"]["marks"], 4)
|
||||
self.assertEqual((artifacts["flows"], artifacts["openWork"]), (["file", "invalid"], 5))
|
||||
for state in ("offene Arbeit: nicht committet, Aufgabe offen", "offene Arbeit: ungepusht, Aufgabe offen", "offene Arbeit: nicht gemergt", "3 Worktrees mit offener Arbeit"):
|
||||
for state in ("open work: not committed, task open", "open work: unpushed, task open", "open work: not merged", "3 worktrees with open work"):
|
||||
with self.subTest(state=state):
|
||||
self.assertIn(state, artifacts["states"])
|
||||
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"], ["Liste", "Board", "RASCI"])
|
||||
self.assertEqual(listed["taskFilters"], [["alle", "true", "6"], ["offen", "false", "4"], ["arbeit", "false", "4"], ["operator", "false", "2"], ["freigabe", "false", "1"], ["beendet", "false", "2"]])
|
||||
self.assertEqual(listed["views"], ["List", "Board", "RASCI"])
|
||||
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", []]])
|
||||
@@ -885,19 +885,19 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/rasci", "#stream"])
|
||||
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
|
||||
self.assertEqual((actions["settingsUnlabelled"], actions["drawerUnlabelled"]), (0, 0))
|
||||
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "SECURITE"])
|
||||
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 ("Funk an alle …", "Mayday aufheben", "Priorität …", "Abend", "Regeln …", "Einstellungen …", "Himmel ansehen", "Leitstand umschalten", "Benachrichtigungen aktivieren"):
|
||||
for tool in ("Radio to all …", "Cancel mayday", "Priority …", "Evening", "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["drawer"], [["post", True], ["call", False]])
|
||||
artifact_pick, agent_pick, calm, chips = extra[4:]
|
||||
self.assertEqual(artifact_pick["panel"], artifact_pick["label"])
|
||||
self.assertEqual(agent_pick["panel"], agent_pick["label"])
|
||||
self.assertRegex(calm["note"], r"Kraft · ohne Bewegung \(prefers-reduced-motion\)$")
|
||||
self.assertRegex(calm["note"], r"Force · without motion \(prefers-reduced-motion\)$")
|
||||
self.assertEqual({key: value for key, value in chips["inbox"].items() if key != "counted"}, {"hash": "#stream/inbox", "items": chips["inbox"]["counted"], "pressed": "true", "current": ["Inbox"]})
|
||||
self.assertGreaterEqual(chips["inbox"]["items"], 1)
|
||||
self.assertEqual(chips["back"], {"hash": "#tasks/list", "rows": [["queue/gate-3", "Queue-Job"]]})
|
||||
self.assertEqual(chips["back"], {"hash": "#tasks/list", "rows": [["queue/gate-3", "Queue job"]]})
|
||||
|
||||
def test_chromium_sends_a_call_from_the_agent_panel_and_shows_its_receipt(self):
|
||||
handled = []
|
||||
@@ -918,7 +918,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
result = data["results"][0]
|
||||
self.assertNotIn("error", result)
|
||||
self.assertIsNotNone(result["receipt"], result)
|
||||
self.assertIn("Gelesen", result["receipt"])
|
||||
self.assertIn("Read", result["receipt"])
|
||||
|
||||
def test_chromium_shows_who_speaks_while_a_message_is_read(self):
|
||||
self.speaks(("e2e-cue-1", "e2e-cue-2"))
|
||||
@@ -932,11 +932,11 @@ class TestOrchestratorE2E(HubCase):
|
||||
(OUT / "chromium-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual(data["errors"], [])
|
||||
speaking, graph, ended, calm = data["results"]
|
||||
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage spricht", "Platte fast voll (e2e-cue-1)", "s2", "speaking"), speaking)
|
||||
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-1)", "s2", "speaking"), speaking)
|
||||
self.assertEqual((graph["node"] or "").split(",")[0], "Stage", graph)
|
||||
self.assertEqual(graph["ring"], "speaking-node")
|
||||
self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0})
|
||||
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage spricht", "Platte fast voll (e2e-cue-2)", "none"), calm)
|
||||
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-2)", "none"), calm)
|
||||
|
||||
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
|
||||
local = f"http://localhost:{self.port}/"
|
||||
@@ -956,18 +956,18 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(composed["recording"], "true")
|
||||
self.assertEqual(composed["text"], "hello from the mic second line", composed)
|
||||
self.assertRegex(composed["replay"] or "", r"^/api/voice/[0-9a-f]{16}\.wav$")
|
||||
self.assertEqual(composed["attach"], ["Aufnahme anhängen", False])
|
||||
self.assertEqual(composed["attach"], ["Attach recording", False])
|
||||
self.assertEqual(composed["clashes"], {"found": [], "outside": []})
|
||||
self.assertEqual(again["attach"], ["Aufnahme anhängen", False])
|
||||
self.assertEqual(again["attach"], ["Attach recording", False])
|
||||
note = again["replay"].removeprefix("/api/voice/").removesuffix(".wav")
|
||||
for result in (plain, sent):
|
||||
self.assertEqual((result["left"], result["voice"]), ("", False), result)
|
||||
self.assertTrue(result["sent"].startswith("Call an Stage: gesendet"), result)
|
||||
self.assertTrue(result["sent"].startswith("Call to Stage: sent"), result)
|
||||
calls = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
|
||||
self.assertEqual(len(calls), 2, self.scripts.calls)
|
||||
self.assertEqual([call[:4] for call in calls], [["send", "call", "--to", "Stage"]] * 2)
|
||||
self.assertTrue(calls[0][-1].endswith("] hello from the mic second line bitte"), calls[0])
|
||||
self.assertTrue(calls[1][-1].endswith(f"] hello from the mic second line bitte voice: {HOST_STATE}/voice/{note}.wav"), calls[1])
|
||||
self.assertTrue(calls[0][-1].endswith("] hello from the mic second line please"), calls[0])
|
||||
self.assertTrue(calls[1][-1].endswith(f"] hello from the mic second line please voice: {HOST_STATE}/voice/{note}.wav"), calls[1])
|
||||
status, _, body = self.get(f"/api/voice/{note}.wav")
|
||||
self.assertEqual((status, body[:4]), (200, b"RIFF"))
|
||||
|
||||
@@ -977,7 +977,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(data["errors"], [])
|
||||
result = data["results"][0]
|
||||
self.assertNotIn("error", result)
|
||||
self.assertTrue((result["done"] or "").startswith("Mayday aufheben: erledigt"), result)
|
||||
self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result)
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
|
||||
|
||||
def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self):
|
||||
@@ -1011,15 +1011,15 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(data["errors"], [])
|
||||
opened, confirmed, after = data["results"]
|
||||
self.assertNotIn("error", opened)
|
||||
self.assertEqual(opened["every"], [False, "Alle gemergten löschen: 1 Branches nach Trockenlauf und Signatur"])
|
||||
self.assertEqual(opened["buttons"], [[False, "Löschen: feature/done lokal entfernen, nach Trockenlauf und Signatur"]])
|
||||
self.assertEqual(opened["title"], "feature/done löschen")
|
||||
self.assertEqual(opened["every"], [False, "Delete all merged: 1 branches after dry run and signature"])
|
||||
self.assertEqual(opened["buttons"], [[False, "Delete: remove feature/done locally, after dry run and signature"]])
|
||||
self.assertEqual(opened["title"], "Delete feature/done")
|
||||
self.assertEqual(len(opened["dry"]), 1)
|
||||
self.assertTrue(opened["dry"][0].startswith("feature/done @ 5b1e6fd3a7c0: merged-clean, gemergt durch #12"), opened["dry"])
|
||||
self.assertIn("bleibt: remote-branch (nur lokal)", opened["dry"][0])
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "feature/donegelöscht"), confirmed)
|
||||
self.assertEqual(confirmed["toasts"], ["Löschen: erledigtfeature/done: gelöscht"])
|
||||
self.assertEqual(after["past"], ["done", "gelöscht"])
|
||||
self.assertTrue(opened["dry"][0].startswith("feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12"), opened["dry"])
|
||||
self.assertIn("stays: remote-branch (local only)", opened["dry"][0])
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "feature/donedeleted"), confirmed)
|
||||
self.assertEqual(confirmed["toasts"], ["Delete: donefeature/done: deleted"])
|
||||
self.assertEqual(after["past"], ["done", "deleted"])
|
||||
self.assertNotIn("feature/done", after["branches"])
|
||||
written = [json.loads(path.read_text(encoding="utf-8")) for path in results.glob("*.json")]
|
||||
self.assertEqual([[(item["branch"], item["status"]) for item in result["items"]] for result in written], [[("feature/done", "done")]])
|
||||
@@ -1053,16 +1053,16 @@ class TestOrchestratorE2E(HubCase):
|
||||
opened, folded, moved, sheet, confirmed, after = data["results"]
|
||||
self.assertNotIn("error", opened)
|
||||
self.assertEqual(opened["focused"], "toggle:dimension:identity")
|
||||
self.assertEqual((folded["focused"], folded["expanded"], folded["label"], folded["hidden"], folded["again"]), ("toggle:dimension:identity", "false", "Aufklappen: identity", True, "true"))
|
||||
self.assertEqual((folded["focused"], folded["expanded"], folded["label"], folded["hidden"], folded["again"]), ("toggle:dimension:identity", "false", "Expand: identity", True, "true"))
|
||||
self.assertNotIn("identity/oidc", folded["seen"])
|
||||
before = folded["before"]
|
||||
self.assertEqual(moved["after"], [before[1], before[0], *before[2:]])
|
||||
self.assertEqual((moved["focused"], moved["disabled"]), (f"down:{before[1]}", False))
|
||||
self.assertTrue(moved["toast"].startswith("Priorität"), moved)
|
||||
self.assertTrue(moved["toast"].startswith("Priority"), moved)
|
||||
self.assertIn(("rasci.py", ["agents", "order", ",".join(moved["after"])]), [(Path(script).name, args) for script, args in self.scripts.calls])
|
||||
self.assertEqual((sheet["title"], sheet["options"], sheet["work"], sheet["focused"]), ("identity/oidc: Übergeben", ["s1"], "oidc line", "SELECT"))
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "identity/oidcerledigt"), confirmed)
|
||||
self.assertIn("Übergeben: erledigtidentity/oidc: erledigt", confirmed["toasts"])
|
||||
self.assertEqual((sheet["title"], sheet["options"], sheet["work"], sheet["focused"]), ("identity/oidc: Hand over", ["s1"], "oidc line", "SELECT"))
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "identity/oidcdone"), confirmed)
|
||||
self.assertIn("Hand over: doneidentity/oidc: done", confirmed["toasts"])
|
||||
self.assertNotIn("error", after)
|
||||
claim = json.loads((self.council / "identity" / "oidc" / "claim.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual((claim["session"], claim["work"]), ("s1", "oidc line"))
|
||||
@@ -1071,7 +1071,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
|
||||
def inbox_work(self) -> None:
|
||||
stage = {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}
|
||||
greet = {"title": SLOW, "workdir": str(self.outside), "description": "zählt", "check": "manual", "status": "queued", "roles": {"Operator": "R", "Skills session": "A"}}
|
||||
greet = {"title": SLOW, "workdir": str(self.outside), "description": "counts", "check": "manual", "status": "queued", "roles": {"Operator": "R", "Skills session": "A"}}
|
||||
push = {"title": "git push", "workdir": "/repo", "description": "ship", "check": "manual", "status": "queued", "roles": {"Operator": "R", "Skills session": "A"}}
|
||||
self.write_council({"rasci.json": {"work/stage": stage, "operator/greet": greet, "operator/push": push}})
|
||||
arguments = {"kind": "ask", "tool": "Bash", "pattern": "make deploy", "scope": "/repo*", "expires": int(time.time()) + 7 * 86400, "agent": "Skills", "why": "deploy runs after every green gate"}
|
||||
@@ -1079,11 +1079,11 @@ class TestOrchestratorE2E(HubCase):
|
||||
|
||||
def layout(self, probe: dict) -> None:
|
||||
self.assertNotIn("error", probe)
|
||||
self.assertEqual(sorted(probe["kinds"]), ["Ask", "Operator", "Operator", "Regelwunsch", "Review"])
|
||||
self.assertIn(["Ablehnen", "Ablehnen über …"], probe["splits"])
|
||||
self.assertEqual(sorted(probe["kinds"]), ["Ask", "Operator", "Operator", "Review", "Rule request"])
|
||||
self.assertIn(["Deny", "Deny via …"], probe["splits"])
|
||||
self.assertEqual(len(probe["splits"]), 4)
|
||||
self.assertEqual(probe["fields"], ["Art", "Werkzeug (Glob)", "Muster (Glob)", "Verzeichnis (Glob)", "Läuft ab am"])
|
||||
self.assertEqual(probe["dry"], "Trockenlauf: Die Regel hätte 1 von 1 Asks der letzten 7 Tage freigegeben.")
|
||||
self.assertEqual(probe["fields"], ["Kind", "Tool (glob)", "Pattern (glob)", "Directory (glob)", "Expires on"])
|
||||
self.assertEqual(probe["dry"], "Dry run: the rule would have granted 1 of 1 asks from the last 7 days.")
|
||||
self.assertEqual(probe["accept"], 1)
|
||||
self.assertTrue(probe["fits"] and probe["followed"], probe)
|
||||
self.assertEqual((probe["unlabelled"], probe["wide"]), ([], False))
|
||||
@@ -1120,32 +1120,32 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(data["errors"], [])
|
||||
started, held, done, *rest = data["results"]
|
||||
self.assertNotIn("error", started)
|
||||
self.assertIn("wird in", started["toast"])
|
||||
self.assertTrue(started["running"].startswith("läuft"), started)
|
||||
self.assertIn("takes effect in", started["toast"])
|
||||
self.assertTrue(started["running"].startswith("running"), started)
|
||||
self.assertTrue(started["overflows"] and started["followed"], started)
|
||||
self.assertLess(held["held"], 50, held)
|
||||
self.assertEqual((done["last"], done["lines"], done["followed"]), ("fertig", 81, True))
|
||||
self.assertRegex(done["done"], r"^erledigtExit-Code 0 nach \d+ s$")
|
||||
self.assertIn("Schließen", done["actions"])
|
||||
self.assertEqual((done["last"], done["lines"], done["followed"]), ("finished", 81, True))
|
||||
self.assertRegex(done["done"], r"^doneExit code 0 after \d+ s$")
|
||||
self.assertIn("Close", done["actions"])
|
||||
layouts, sheets = rest[0:6:2], rest[1:6:2]
|
||||
for probe, sheet, (width, _) in zip(layouts, sheets, WIDTHS, strict=True):
|
||||
with self.subTest(width=width):
|
||||
self.layout(probe)
|
||||
self.assertTrue(probe["console"].startswith("erledigtExit-Code 0"), probe)
|
||||
self.assertEqual(sheet["title"], "Ablehnen")
|
||||
self.assertTrue(probe["console"].startswith("doneExit code 0"), probe)
|
||||
self.assertEqual(sheet["title"], "Deny")
|
||||
self.assertEqual(sheet["channels"], [["post", True], ["call", False], ["newsletter", False], ["broadcast", False], ["pan-pan", False], ["security", False]])
|
||||
self.assertEqual(sheet["hints"][:3], ["an Skills, sobald sein Zug endet", "an Skills, sofort, weckt ihn", "an alle, sobald ihr Zug endet"])
|
||||
self.assertEqual(sheet["hints"][:3], ["to Skills, when its turn ends", "to Skills, at once, wakes it", "to all, when their turn ends"])
|
||||
self.assertEqual((sheet["reason"], sheet["unlabelled"], sheet["wide"]), (True, [], False))
|
||||
denied, gone, accepted = rest[6:]
|
||||
self.assertTrue(denied["undo"].startswith("Ablehnung (Call): Bash: make deploy"), denied)
|
||||
self.assertTrue(denied["undo"].startswith("Denial (Call): Bash: make deploy"), denied)
|
||||
self.assertFalse(denied["sheet"])
|
||||
self.assertTrue(gone["gone"])
|
||||
self.assertEqual((accepted["toast"], accepted["left"]), ("Angelegt: Regel: ask Bash make deploy in /repo*", False))
|
||||
self.assertEqual((accepted["toast"], accepted["left"]), ("Created: Rule: ask Bash make deploy in /repo*", False))
|
||||
ask = self.httpd.hub.asks.listed()[0]
|
||||
self.assertEqual((ask["status"], ask["reason"], ask["channel"]), ("denied", "erst das Gate grün machen", "call"))
|
||||
self.assertEqual((ask["status"], ask["reason"], ask["channel"]), ("denied", "make the gate green first", "call"))
|
||||
calls = [args for script, args in self.scripts.calls if script.endswith("radio.py")]
|
||||
self.assertEqual(calls[-1][:4], ["send", "call", "--to", "Skills session"])
|
||||
self.assertTrue(calls[-1][-1].endswith("Reason: erst das Gate grün machen"), calls[-1])
|
||||
self.assertTrue(calls[-1][-1].endswith("Reason: make the gate green first"), calls[-1])
|
||||
self.assertEqual([(rule["kind"], rule["pattern"], rule["proof"] is not None) for rule in self.httpd.hub.rules.listed()], [("ask", "make deploy", True)])
|
||||
self.assertEqual([(job["status"], job["code"]) for job in self.httpd.hub.jobs.listed()], [("done", 0)])
|
||||
|
||||
@@ -1154,7 +1154,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.inbox_work()
|
||||
now = time.time()
|
||||
fields = {"task": "operator/greet", "command": SLOW, "directory": str(self.outside), "sha256": "", "status": "done", "proof": None}
|
||||
output = "".join(f"Zeile {number}\n" for number in range(1, 81)) + "fertig\n"
|
||||
output = "".join(f"Row {number}\n" for number in range(1, 81)) + "finished\n"
|
||||
self.httpd.hub.jobs.add({**fields, "code": 0, "output": output, "created_ts": now - 20, "started": now - 19, "finished": now - 10, "dismissed": False})
|
||||
shots = [(width, height, OUT / f"firefox-{width}-inbox-console.png") for width, height in WIDTHS]
|
||||
steps = [{"width": width, "height": height, "url": f"{self.base}#stream/inbox", "wait": 1200, "probe": LAYOUT, "shot": str(shot)} for width, height, shot in shots]
|
||||
@@ -1164,7 +1164,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
for probe, (width, _, shot) in zip(data["results"], shots, strict=True):
|
||||
with self.subTest(width=width):
|
||||
self.layout(probe)
|
||||
self.assertEqual(probe["console"], "erledigtExit-Code 0 nach 9 s")
|
||||
self.assertEqual(probe["console"], "doneExit code 0 after 9 s")
|
||||
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
|
||||
|
||||
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
|
||||
@@ -1178,7 +1178,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
pinned = data["results"][0]
|
||||
self.assertNotIn("error", pinned)
|
||||
self.assertEqual((pinned["title"], pinned["preview"][0]), ("work/docs", "rasci.py set work/docs --prio 2"))
|
||||
self.assertEqual(pinned["toasts"], ["Priorität work/docs: an den Runner übergebenrasci.py set work/docs --prio 2"])
|
||||
self.assertEqual(pinned["toasts"], ["Priority work/docs: handed to the runnerrasci.py set work/docs --prio 2"])
|
||||
jobs = self.runner_jobs()
|
||||
self.assertEqual([(job["id"], job["kind"], job["argv"]) for job in jobs], [(pinned["jobs"], "task", ["set", "work/docs", "--prio", "2"])])
|
||||
self.run_runner()
|
||||
|
||||
@@ -59,9 +59,9 @@ HOOK = {
|
||||
OPTIONS = [{"label": "Merge now", "description": "Gate is green"}, {"label": "Wait", "description": ""}]
|
||||
AMBIENCE = HUB / "templates" / "ambience.js"
|
||||
STREAMS = [
|
||||
{"name": "Morgen", "url": "https://radio.example/morgen", "from": "07:00", "to": "09:00", "days": [0, 1, 2, 3, 4], "on": True, "via": []},
|
||||
{"name": "Nacht", "url": "http://10.0.0.5:8000/nacht", "from": "22:00", "to": "02:00", "days": [5], "on": True, "via": []},
|
||||
{"name": "Aus", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False, "via": []},
|
||||
{"name": "Morning", "url": "https://radio.example/morning", "from": "07:00", "to": "09:00", "days": [0, 1, 2, 3, 4], "on": True, "via": []},
|
||||
{"name": "Night", "url": "http://10.0.0.5:8000/night", "from": "22:00", "to": "02:00", "days": [5], "on": True, "via": []},
|
||||
{"name": "Off", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False, "via": []},
|
||||
]
|
||||
|
||||
|
||||
|
||||
+10
-10
@@ -34,28 +34,28 @@ class TestCoach(unittest.TestCase):
|
||||
return {row["area"]: row for row in json.loads(result.stdout)}
|
||||
|
||||
def test_a_dislike_replaces_a_like_and_forget_drops_it(self):
|
||||
self._run("like", "body", "Laufen")
|
||||
self._run("like", "body", "Laufen")
|
||||
self._run("like", "body", "Running")
|
||||
self._run("like", "body", "Running")
|
||||
self._run("like", "body", "Yoga")
|
||||
self._run("dislike", "body", "Laufen")
|
||||
self._run("dislike", "body", "Running")
|
||||
body = self._show("2026-11-03T18:00:00+00:00")["body"]
|
||||
self.assertEqual(body["likes"], ["Yoga"])
|
||||
self.assertEqual(body["dislikes"], ["Laufen"])
|
||||
self._run("forget", "body", "Laufen")
|
||||
self.assertEqual(body["dislikes"], ["Running"])
|
||||
self._run("forget", "body", "Running")
|
||||
self.assertEqual(self._show("2026-11-03T18:00:00+00:00")["body"]["dislikes"], [])
|
||||
|
||||
def test_the_latest_done_per_area_gives_the_hours_since(self):
|
||||
self._run("done", "food", "Frühstück", "--at", "2026-11-03T08:00:00+00:00")
|
||||
self._run("done", "food", "Mittagessen", "--at", "2026-11-03T12:30:00+00:00")
|
||||
self._run("done", "sleep", "Nacht", "--at", "2026-11-02T23:00:00+00:00")
|
||||
self._run("done", "food", "Breakfast", "--at", "2026-11-03T08:00:00+00:00")
|
||||
self._run("done", "food", "Lunch", "--at", "2026-11-03T12:30:00+00:00")
|
||||
self._run("done", "sleep", "Night", "--at", "2026-11-02T23:00:00+00:00")
|
||||
rows = self._show("2026-11-03T18:00:00+00:00")
|
||||
self.assertEqual((rows["food"]["last"], rows["food"]["hours_ago"]), ("Mittagessen", 5.5))
|
||||
self.assertEqual((rows["food"]["last"], rows["food"]["hours_ago"]), ("Lunch", 5.5))
|
||||
self.assertEqual(rows["sleep"]["hours_ago"], 19.0)
|
||||
self.assertIsNone(rows["psyche"]["last"])
|
||||
|
||||
def test_invalid_input_exits_2_and_keeps_the_profile(self):
|
||||
self.assertEqual(self._run("like", "work", "x").returncode, 2)
|
||||
self.assertEqual(self._run("done", "body", "x", "--at", "gestern").returncode, 2)
|
||||
self.assertEqual(self._run("done", "body", "x", "--at", "yesterday").returncode, 2)
|
||||
self.assertFalse(self.profile.exists())
|
||||
self.profile.parent.mkdir(parents=True)
|
||||
self.profile.write_text("{broken", encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pin the repository to English: no German text outside the operator's quoted trigger phrases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WORDS = (
|
||||
"und", "nicht", "oder", "wird", "werden", "wurde", "kein", "keine", "keinen", "eine", "einen", "einem", "einer",
|
||||
"ist", "sind", "auf", "für", "bitte", "noch", "schon", "alle", "aus", "zum", "zur", "bei", "beim", "nach", "über",
|
||||
"dieser", "diese", "dieses", "jetzt", "heißt", "sich", "auch", "wenn", "dann", "mehr", "kann", "muss", "soll",
|
||||
"gibt", "ohne", "vom", "von", "läuft", "wählen", "speichern", "löschen", "abbrechen", "schließen", "bearbeiten",
|
||||
"anzeigen", "öffnen", "zeigen", "aufnehmen", "vorlesen", "regeln", "regel", "regelwunsch", "steuerung",
|
||||
"ausführen", "ressourcen", "hierarchie", "freigabe", "freigeben", "ablehnen", "warten", "wartet", "offen",
|
||||
"erledigt", "neu", "neuer", "angenommen", "abgelehnt", "gestoppt", "abgelaufen", "unbekannt",
|
||||
"fehlgeschlagen", "verweigert", "besetzt", "abberufen", "aufgabe", "aufgaben", "entfernt", "entfernen", "gemergt",
|
||||
"bleibt", "stoppt", "erzwingt", "verwirft", "hintergrund", "kategorien", "ansicht", "quelle", "namen", "suchen",
|
||||
"anheften", "entscheiden", "entscheidungen", "nacht", "abend", "himmel", "sonnenuntergang", "sonnenaufgang",
|
||||
"aufnahme", "nachricht", "nachrichten", "einstellungen", "sprache", "stumm", "fertig", "fehler", "gesperrt",
|
||||
"signiert", "heute", "gestern", "stunden", "minuten", "sekunden", "warnung", "hinweis", "sofort", "zeit",
|
||||
"weiter", "kopieren", "kopiert", "starten", "gestartet", "beenden", "beendet",
|
||||
)
|
||||
GERMAN = re.compile(r"[äöüÄÖÜß]|\b(?:" + "|".join(WORDS) + r")\b", re.IGNORECASE)
|
||||
ALLOWED = (
|
||||
'"rechtlich prüfen"', '"lizenz prüfen"', '"aufräumen"', '"branches aufräumen"', '"worktrees aufräumen"',
|
||||
'"was soll ich tun"', '"was muss ich tun"', '"was fehlt noch"', '"welche skills gibt es"', '"skill übersicht"',
|
||||
'"übersicht"', '"zuständigkeiten"', '"verwaiste branches"', '"alte worktrees"', '"was kann weg"', '"ä"',
|
||||
"(Regeln → Firewall, Hierarchie → Hierarchy, Ressourcen → Resources,", "Steuerung → Controls, Ausführen → Run)",
|
||||
)
|
||||
SELF = Path(__file__).resolve().relative_to(REPO_ROOT).as_posix()
|
||||
|
||||
|
||||
def german_lines() -> list[str]:
|
||||
"""Returns:
|
||||
Every `path:line` of a tracked text file that carries German once the
|
||||
allowed trigger phrases are removed from it.
|
||||
"""
|
||||
files = subprocess.run(["git", "ls-files"], cwd=REPO_ROOT, capture_output=True, text=True, check=True).stdout.split()
|
||||
hits = []
|
||||
for name in files:
|
||||
if name == SELF:
|
||||
continue
|
||||
try:
|
||||
text = (REPO_ROOT / name).read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, IsADirectoryError, FileNotFoundError):
|
||||
continue
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
for phrase in ALLOWED:
|
||||
line = line.replace(phrase, "")
|
||||
if GERMAN.search(line):
|
||||
hits.append(f"{name}:{number}")
|
||||
return hits
|
||||
|
||||
|
||||
class TestEnglish(unittest.TestCase):
|
||||
def test_no_german_outside_quoted_trigger_phrases(self):
|
||||
self.assertEqual(german_lines(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n".join(german_lines()))
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
|
||||
from hub_support import AMBIENCE, STREAMS
|
||||
|
||||
SLOTS = [(0, 420, "Morgen"), (0, 539, "Morgen"), (0, 540, None), (1, 419, None), (5, 480, None), (5, 1380, "Nacht"), (6, 90, "Nacht"), (6, 120, None), (4, 1380, None), (6, 1380, None)]
|
||||
SLOTS = [(0, 420, "Morning"), (0, 539, "Morning"), (0, 540, None), (1, 419, None), (5, 480, None), (5, 1380, "Night"), (6, 90, "Night"), (6, 120, None), (4, 1380, None), (6, 1380, None)]
|
||||
PROBE = """
|
||||
const A = require(process.argv[1]);
|
||||
const at = (text) => Date.parse(text);
|
||||
|
||||
@@ -344,9 +344,9 @@ class TestPasskeyPage(unittest.TestCase):
|
||||
def test_the_section_and_a_failed_registration_name_the_keepassxc_localhost_setting(self):
|
||||
done = subprocess.run(["node", "-e", PASSKEY_PROBE, str(PASSKEY_PAGE)], capture_output=True, text=True, timeout=60, check=True)
|
||||
shown = json.loads(done.stdout)
|
||||
self.assertIn("nicht registriert: NotAllowedError", shown["failed"])
|
||||
self.assertIn("not registered: NotAllowedError", shown["failed"])
|
||||
for where in ("section", "failed"):
|
||||
for text in ("Nutzung von localhost mit Passkeys zulassen", "Allow using localhost with passkeys", "Passkeys request canceled.", "entsperrt"):
|
||||
for text in ("Allow using localhost with passkeys", "Passkeys request canceled.", "unlocked"):
|
||||
with self.subTest(where=where, text=text):
|
||||
self.assertIn(text, shown[where])
|
||||
|
||||
|
||||
@@ -124,16 +124,16 @@ class TestHubBoard(HubCase):
|
||||
for action, other in (("clear-mayday", "sunrise"), ("evening", "sunset"), ("sunset", "evening"), ("sunrise", "clear-mayday")):
|
||||
with self.subTest(action=action):
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {}, good)[0], 400)
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": None}, good)[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": None}, good)[:3:2], (400, b"not signed: no signature\n"))
|
||||
stranger = key(-7, "stranger").proof(self.board_proof(action, signer)["payload"], self.origin)
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": stranger}, good)[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": stranger}, good)[:3:2], (400, b"not signed: the signing key is not pinned\n"))
|
||||
proof = self.board_proof(action, signer)
|
||||
self.assertEqual(self.post_json(f"/api/board/{other}", {"proof": proof}, good)[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json(f"/api/board/{other}", {"proof": proof}, good)[:3:2], (400, b"not signed: the signature does not list this item\n"))
|
||||
self.assertEqual(self.scripts.calls, [])
|
||||
proof = self.board_proof("clear-mayday", signer)
|
||||
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[0], 200)
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
|
||||
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
|
||||
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[:3:2], (400, b"not signed: this signature was already used for this item\n"))
|
||||
for payload in ({"action": "order"}, {"action": "broadcast"}, {"action": "reboot"}, {"action": 1}, {}, {"action": "evening", "extra": 1}):
|
||||
with self.subTest(payload=payload):
|
||||
self.assertEqual(self.post_json("/api/board/sign", payload, good)[0], 400)
|
||||
@@ -189,7 +189,7 @@ class TestHubBoard(HubCase):
|
||||
{"name": "snooze", "arguments": {"ids": [ask_id], "hours": 500}},
|
||||
{"name": "view", "arguments": {"name": "funk", "extra": 1}},
|
||||
]}))
|
||||
status, _, body = self.post_json("/api/nl", {"text": "gib alles Grüne frei"}, self.guard(self.host))
|
||||
status, _, body = self.post_json("/api/nl", {"text": "approve everything green"}, self.guard(self.host))
|
||||
self.assertEqual(status, 200, body)
|
||||
result = json.loads(body)
|
||||
self.assertEqual([action["name"] for action in result["actions"]], ["approve", "choose", "message", "view", "snooze"])
|
||||
|
||||
@@ -62,12 +62,12 @@ class TestHubCleanup(HubCase):
|
||||
done = branches["feature/done"]
|
||||
self.assertEqual(done["cleanup"], item)
|
||||
self.assertEqual(done["plan"], [
|
||||
"feature/done @ 5b1e6fd3a7c0: merged-clean, gemergt durch #12",
|
||||
f"entfernt den Worktree {item['worktree']}",
|
||||
"löscht den lokalen Branch feature/done",
|
||||
"bleibt: remote-branch (nur lokal)",
|
||||
"stoppt den Compose-Stack demo-feature-done ohne -v",
|
||||
"entfernt das Image demo:feature-done",
|
||||
"feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12",
|
||||
f"removes the worktree {item['worktree']}",
|
||||
"deletes the local branch feature/done",
|
||||
"stays: remote-branch (local only)",
|
||||
"stops the Compose stack demo-feature-done without -v",
|
||||
"removes the image demo:feature-done",
|
||||
])
|
||||
self.assertEqual((done["merged_by"], done["merged_into"], done["gone"]), ("#12", "main", True))
|
||||
self.assertEqual({name: entry["cleanup"] for name, entry in branches.items() if name != "feature/done"}, {"feature": None, "feature/kept": None, "main": None})
|
||||
@@ -76,7 +76,7 @@ class TestHubCleanup(HubCase):
|
||||
def test_signing_a_delete_needs_delete_ready_and_exactly_an_offered_item(self):
|
||||
item = self.ready(False)
|
||||
status, body = self.sign([item])
|
||||
self.assertEqual((status, body.decode()), (400, "löschen gesperrt: Secret Service locked\n"))
|
||||
self.assertEqual((status, body.decode()), (400, "delete blocked: Secret Service locked\n"))
|
||||
item = self.ready(True)
|
||||
kept = {**item, "branch": "feature/kept", "worktree": "", "tip": "0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d"}
|
||||
for name, items in (("changed tip", [{**item, "tip": OTHER_TIP}]), ("skip reason", [kept]), ("pattern", [{**item, "branch": "feature/*"}]), ("twice", [item, item]), ("extra key", [{**item, "command": "rm -rf /"}])):
|
||||
|
||||
+17
-17
@@ -115,21 +115,21 @@ class TestHubComposer(HubCase):
|
||||
def test_an_operator_message_needs_the_passkey_and_carries_its_recording(self):
|
||||
self.claim("s1", "Skills session")
|
||||
voice = self.note(self.clip(b"\x01\x00"))
|
||||
payload = {"to": "Skills", "text": " bitte rebasen ", "upload": "", "class": "call", "voice": voice}
|
||||
payload = {"to": "Skills", "text": " please rebase ", "upload": "", "class": "call", "voice": voice}
|
||||
status, _, body = self.post_json("/api/messages/sign", payload, self.guard(self.host))
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(json.loads(body)["credentials"], [])
|
||||
unpinned = key(-7, "stranger").proof(json.loads(body)["payload"], self.origin)
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": unpinned}, self.guard(self.host))[:3:2], (400, b"nicht signiert: no key is pinned\n"))
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": unpinned}, self.guard(self.host))[:3:2], (400, b"not signed: no key is pinned\n"))
|
||||
signer = self.pin()
|
||||
proof = self.message_proof(payload, signer)
|
||||
for changed, reason in (
|
||||
({"proof": None}, b"nicht signiert: no signature\n"),
|
||||
({"proof": key(-7, "stranger").proof(proof["payload"], self.origin)}, b"nicht signiert: the signing key is not pinned\n"),
|
||||
({"proof": None}, b"not signed: no signature\n"),
|
||||
({"proof": key(-7, "stranger").proof(proof["payload"], self.origin)}, b"not signed: the signing key is not pinned\n"),
|
||||
({"proof": signer.proof(proof["payload"], "http://127.0.0.1:1")}, None),
|
||||
({"text": "bitte mergen", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||||
({"class": "post", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||||
({"voice": "", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||||
({"text": "please merge", "proof": proof}, b"not signed: the signature does not list this item\n"),
|
||||
({"class": "post", "proof": proof}, b"not signed: the signature does not list this item\n"),
|
||||
({"voice": "", "proof": proof}, b"not signed: the signature does not list this item\n"),
|
||||
):
|
||||
with self.subTest(changed=list(changed)):
|
||||
status, _, answer = self.post_json("/api/messages", {**payload, **changed}, self.guard(self.host))
|
||||
@@ -141,10 +141,10 @@ class TestHubComposer(HubCase):
|
||||
self.assertEqual(status, 201, body)
|
||||
sent = json.loads(body)
|
||||
recording = f"{HOST_STATE}/voice/{voice}.wav"
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["send", "call", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] bitte rebasen voice: {recording}"])])
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
|
||||
verified = self.check(sent["id"], "bitte rebasen", "Skills session")["structuredContent"]
|
||||
self.assertEqual((verified["match"], verified["text"], verified["class"], verified["voice"]), (True, "bitte rebasen", "call", recording))
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["send", "call", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] please rebase voice: {recording}"])])
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"not signed: this signature was already used for this item\n"))
|
||||
verified = self.check(sent["id"], "please rebase", "Skills session")["structuredContent"]
|
||||
self.assertEqual((verified["match"], verified["text"], verified["class"], verified["voice"]), (True, "please rebase", "call", recording))
|
||||
row = self.snapshot()["messages"][-1]
|
||||
self.assertEqual((row["id"], row["class"], row["voice"], row["signed"]), (sent["id"], "call", voice, True))
|
||||
self.assertEqual(len(self.scripts.calls), 1)
|
||||
@@ -176,18 +176,18 @@ class TestHubComposer(HubCase):
|
||||
self.claim("s1", "Skills session")
|
||||
signer = self.pin()
|
||||
voice = self.note(self.clip(b"\x01\x00"))
|
||||
status, _, body = self.say({"to": "Skills", "text": "bitte\n rebasen", "class": "call", "voice": voice}, signer)
|
||||
status, _, body = self.say({"to": "Skills", "text": "please\n rebase", "class": "call", "voice": voice}, signer)
|
||||
self.assertEqual(status, 201, body)
|
||||
sent = json.loads(body)["id"]
|
||||
row = self.scripts.calls[-1][1][-1].split("] ", 1)[1]
|
||||
for text, to in (("bitte rebasen", "Skills"), ("bitte rebasen", "Skills session"), (row, "Skills")):
|
||||
for text, to in (("please rebase", "Skills"), ("please rebase", "Skills session"), (row, "Skills")):
|
||||
with self.subTest(text=text, to=to):
|
||||
result = self.check(sent, text, to)
|
||||
self.assertFalse(result["isError"], result)
|
||||
found = result["structuredContent"]
|
||||
self.assertEqual((found["match"], found["text"], found["to"], found["class"]), (True, "bitte\n rebasen", "Skills", "call"))
|
||||
self.assertEqual((found["match"], found["text"], found["to"], found["class"]), (True, "please\n rebase", "Skills", "call"))
|
||||
self.assertEqual(found["voice"], f"{HOST_STATE}/voice/{voice}.wav")
|
||||
for text, to in (("bitte mergen", "Skills"), ("bitte rebasen", "Stage"), ("bitte rebasen", "*"), ("bitte", "Skills")):
|
||||
for text, to in (("please merge", "Skills"), ("please rebase", "Stage"), ("please rebase", "*"), ("please", "Skills")):
|
||||
with self.subTest(text=text, to=to):
|
||||
result = self.check(sent, text, to)
|
||||
self.assertTrue(result["isError"], result)
|
||||
@@ -256,7 +256,7 @@ class TestHubComposer(HubCase):
|
||||
payload = {"to": "Skills", "text": "hear this", "upload": "", "class": "call", "voice": voice}
|
||||
proof = self.message_proof(payload, signer)
|
||||
wav.write_bytes(self.clip(b"\x02\x00"))
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"not signed: the signature does not list this item\n"))
|
||||
self.assertEqual(self.scripts.calls, [])
|
||||
wav.write_bytes(original)
|
||||
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
|
||||
@@ -273,7 +273,7 @@ class TestHubComposer(HubCase):
|
||||
with mock.patch.object(signed.passkey, "serialization", None), mock.patch.object(tempfile, "tempdir", str(missing)):
|
||||
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertTrue(body.startswith(b"nicht signiert: "), body)
|
||||
self.assertTrue(body.startswith(b"not signed: "), body)
|
||||
self.assertEqual(self.scripts.calls, [])
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class TestHubConsole(HubCase):
|
||||
|
||||
def test_the_page_draws_the_console_as_text_and_follows_its_end(self):
|
||||
html = self.index()
|
||||
for text in ('"/api/inbox/dismiss"', "console-out", "wartet auf den Runner", "Exit-Code", "textContent", "scrollHeight"):
|
||||
for text in ('"/api/inbox/dismiss"', "console-out", "waiting for the runner", "Exit code", "textContent", "scrollHeight"):
|
||||
with self.subTest(text=text):
|
||||
self.assertIn(text, html)
|
||||
for name in ("page-console.js", "page-deny.js", "page-rule-request.js"):
|
||||
|
||||
@@ -80,9 +80,9 @@ class TestHubDeny(HubCase):
|
||||
denial = self.httpd.hub.messages.get(message_id)["denial"]
|
||||
self.httpd.hub.messages.change(message_id, {"denial": {**denial, "reason": "push anyway"}})
|
||||
self.assertTrue(self.tool("operator_message", {"id": message_id, "text": told, "to": "Skills session"})["isError"])
|
||||
logged = [row for row in self.events("kinds=approval")["events"] if row["title"].startswith("Ablehnung ask")]
|
||||
logged = [row for row in self.events("kinds=approval")["events"] if row["title"].startswith("Denial ask")]
|
||||
self.assertEqual(len(logged), 1)
|
||||
self.assertIn("call an Skills session", logged[0]["text"])
|
||||
self.assertIn("call to Skills session", logged[0]["text"])
|
||||
self.assertNotIn(ask_id, [item["id"] for item in self.snapshot()["inbox"]])
|
||||
|
||||
def test_every_class_but_mayday_reaches_the_agent_or_all(self):
|
||||
@@ -121,7 +121,7 @@ class TestHubDeny(HubCase):
|
||||
self.assertTrue(self.radio()[0][-1].endswith("Denied by the operator: operator command git push in /repo (from Skills). Reason: push after the review"), self.radio()[0])
|
||||
self.assertEqual(len(self.radio()), 1)
|
||||
texts = [row["text"] for row in self.events("kinds=approval")["events"]]
|
||||
self.assertTrue(any("kein Empfänger" in text for text in texts), texts)
|
||||
self.assertTrue(any("no recipient" in text for text in texts), texts)
|
||||
|
||||
def test_a_forged_denial_is_refused_before_anything_is_stored_or_sent(self):
|
||||
self.claim("s1", "Skills session")
|
||||
@@ -134,7 +134,7 @@ class TestHubDeny(HubCase):
|
||||
with self.subTest(name=name), mock.patch.object(batches, "UNDO", 0.05):
|
||||
status, _, body = self.approve(entries, proof)
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertIn("nicht signiert", body.decode())
|
||||
self.assertIn("not signed", body.decode())
|
||||
self.assertFalse(self.httpd.hub.batches.holds("ask", ask_id))
|
||||
time.sleep(0.2)
|
||||
self.assertEqual((self.ask_status(ask_id, "0"), self.radio()), ("pending", []))
|
||||
@@ -182,7 +182,7 @@ class TestHubDeny(HubCase):
|
||||
self.assertEqual(self.prepare([entry])["payload"]["items"], [signing.item_hash(signing.denial_item(subject, "call", "not now", ""))])
|
||||
with mock.patch.object(batches, "UNDO", 0.05):
|
||||
self.assertEqual(self.approve([entry], self.signed([entry], self.pin()))[0], 202)
|
||||
self.settle(lambda: any("kein Empfänger" in row["text"] for row in self.events("kinds=approval")["events"]))
|
||||
self.settle(lambda: any("no recipient" in row["text"] for row in self.events("kinds=approval")["events"]))
|
||||
self.assertEqual((self.ask_status(ask_id, "0"), self.radio()), ("denied", []))
|
||||
|
||||
def answer(self, signer: Key, item: dict, channel: str, reason: str) -> dict:
|
||||
|
||||
@@ -81,7 +81,7 @@ class TestHubGraph(HubCase):
|
||||
("rename", "s1", "IAM", "IAM"),
|
||||
("radio", "s1", "IAM", "IAM"),
|
||||
])
|
||||
self.assertEqual(rows[1]["title"], "Skills heißt jetzt IAM")
|
||||
self.assertEqual(rows[1]["title"], "Skills is now called IAM")
|
||||
self.assertAlmostEqual(rows[1]["ts"], now - 50, places=3)
|
||||
for name in ("Skills", "IAM"):
|
||||
with self.subTest(name=name):
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestHubInbox(HubCase):
|
||||
decision_id = self.decision("Merge now")
|
||||
inbox = {item["id"]: item for item in self.snapshot()["inbox"]}
|
||||
self.assertEqual([inbox[key]["green"] for key in (green, red, ask_id, risky, decision_id)], [True, False, True, False, True])
|
||||
self.assertEqual(inbox[risky]["warnings"], ["löscht rekursiv"])
|
||||
self.assertEqual(inbox[risky]["warnings"], ["deletes recursively"])
|
||||
batch = [self.entry("review", green, "", ""), self.entry("ask", ask_id, "", ""), self.entry("decision", decision_id, "Merge now", "")]
|
||||
signer = key(-7, "main")
|
||||
self.assertEqual(self.approve([self.entry("review", green, "", ""), self.entry("review", red, "", "")], None)[0], 400)
|
||||
@@ -77,8 +77,8 @@ class TestHubInbox(HubCase):
|
||||
with self.subTest(payload=payload):
|
||||
self.assertEqual(self.post_json("/api/inbox/mark", payload, self.guard(self.host))[0], 400)
|
||||
titles = [row["title"] for row in self.events("kinds=approval")["events"]]
|
||||
self.assertIn("Freigabe review: feat: green", titles)
|
||||
self.assertTrue(any(title.startswith("Rückgängig: ") for title in titles))
|
||||
self.assertIn("Grant review: feat: green", titles)
|
||||
self.assertTrue(any(title.startswith("Undone: ") for title in titles))
|
||||
|
||||
def test_the_sign_endpoint_and_the_page_offer_the_pinned_keys_only(self):
|
||||
ask_id = self.create_ask(ASK)
|
||||
@@ -96,7 +96,7 @@ class TestHubInbox(HubCase):
|
||||
self.approvers.chmod(0o666)
|
||||
self.assertEqual(self.snapshot()["approvers"], [])
|
||||
html = self.index()
|
||||
for text in ("Passkey registrieren", "navigator.credentials.get", "navigator.credentials.create", "approval.py pin --port", "~/.config/kvb-orchestrator/approvers.json", "nicht signiert"):
|
||||
for text in ("Register passkey", "navigator.credentials.get", "navigator.credentials.create", "approval.py pin --port", "~/.config/kvb-orchestrator/approvers.json", "not signed"):
|
||||
with self.subTest(text=text):
|
||||
self.assertIn(text, html)
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ class TestRenameOnce(unittest.TestCase):
|
||||
self.assertEqual(counts, [0, 0])
|
||||
for build in (3000.0, 4000.0):
|
||||
book.observe([*claims, ("s1", "Skills", 300.0, "title")], build)
|
||||
self.assertEqual([row["title"] for row in names.Directory(book.listed()).renames(0, 5000)], ["i18n 2.0 heißt jetzt Skills"])
|
||||
self.assertEqual([row["title"] for row in names.Directory(book.listed()).renames(0, 5000)], ["i18n 2.0 is now called Skills"])
|
||||
|
||||
def test_a_name_pair_shows_once_per_session(self):
|
||||
entries = [{"session": "s1", "name": name, "first_seen": ts, "source": "claim"} for name, ts in (("A", 1), ("B", 2), ("A", 3), ("B", 4))]
|
||||
self.assertEqual([row["title"] for row in names.Directory(entries).renames(0, 10)], ["A heißt jetzt B", "B heißt jetzt A"])
|
||||
self.assertEqual([row["title"] for row in names.Directory(entries).renames(0, 10)], ["A is now called B", "B is now called A"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -34,16 +34,16 @@ class TestNavModule(unittest.TestCase):
|
||||
cls.data = json.loads(done.stdout)
|
||||
|
||||
def test_the_navigation_has_four_entries_and_stream_has_inbox_rules_and_radio(self):
|
||||
stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]]
|
||||
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_three(self):
|
||||
shared = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]
|
||||
self.assertEqual((self.data["views"], self.data["artifacts"]), (shared, shared))
|
||||
self.assertEqual((self.data["taskViews"], self.data["tasks"]), ([["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]],) * 2)
|
||||
self.assertEqual((self.data["taskViews"], self.data["tasks"]), ([["list", "List"], ["board", "Board"], ["rasci", "RASCI"]],) * 2)
|
||||
|
||||
def test_only_agents_add_the_hierarchy_after_3d(self):
|
||||
self.assertEqual(self.data["agents"][-2:], [["3d", "3D"], ["hierarchy", "Hierarchie"]])
|
||||
self.assertEqual(self.data["agents"][-2:], [["3d", "3D"], ["hierarchy", "Hierarchy"]])
|
||||
self.assertEqual(self.data["icon"], "bi-diagram-2")
|
||||
|
||||
def test_hashes_route_and_the_old_ones_redirect(self):
|
||||
|
||||
@@ -8,8 +8,8 @@ from hub_support import AMBIENCE
|
||||
from kvb_orchestrator.settings import stream_origin
|
||||
|
||||
DLF = "https://st01.sslstream.dlf.de/dlf/01/high/aac/stream.aac?aggregator=web"
|
||||
OTHER = "https://radio.example/morgen"
|
||||
URLS = [DLF, "https://Radio.Example:443/zweiter?x=1", "http://10.0.0.5:8000/nacht", "https://other.example:8443/", "http://radio.example:80/x"]
|
||||
OTHER = "https://radio.example/morning"
|
||||
URLS = [DLF, "https://Radio.Example:443/second?x=1", "http://10.0.0.5:8000/night", "https://other.example:8443/", "http://radio.example:80/x"]
|
||||
PROBE = """
|
||||
const A = require(process.argv[1]);
|
||||
const [DLF, OTHER] = JSON.parse(process.argv[2]);
|
||||
|
||||
@@ -221,7 +221,7 @@ class TestRedirects(unittest.TestCase):
|
||||
|
||||
def test_only_https_urls_are_probed(self):
|
||||
before = len(cdn().dialed)
|
||||
self.assertEqual(self.resolver().resolve(["http://10.0.0.5:8000/nacht", STATION]), {"http://10.0.0.5:8000/nacht": ([], ""), STATION: (["https://*.cdn.test"], "")})
|
||||
self.assertEqual(self.resolver().resolve(["http://10.0.0.5:8000/night", STATION]), {"http://10.0.0.5:8000/night": ([], ""), STATION: (["https://*.cdn.test"], "")})
|
||||
self.assertEqual(len(cdn().dialed) - before, 2)
|
||||
|
||||
def test_media_sources_widen_one_label_and_pass_the_settings_check(self):
|
||||
@@ -284,9 +284,9 @@ class TestRedirectSettings(HubCase):
|
||||
self.assertEqual(set(prepared), {"payload", "challenge", "credentials"})
|
||||
self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.stream_item(["https://attacker.radio.test"], [streams[0]["url"]]))])
|
||||
unsigned = self.post_json("/api/settings", {**self.payload(streams), "proof": None}, self.guard(self.host))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"not signed: no signature\n"))
|
||||
stranger = {**self.payload(streams), "proof": key(-7, "stranger").proof(prepared["payload"], self.origin)}
|
||||
self.assertEqual(self.post_json("/api/settings", stranger, self.guard(self.host))[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", stranger, self.guard(self.host))[:3:2], (400, b"not signed: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.dials(), before)
|
||||
self.assertNotIn("attacker.radio.test", cdn().httpd.asked)
|
||||
self.assertFalse((self.state / "settings.json").exists())
|
||||
@@ -326,7 +326,7 @@ class TestRedirectSettings(HubCase):
|
||||
self.assertEqual(self.prepared(streams)["payload"]["items"], [signing.item_hash(signing.stream_item(["https://station.radio.test"], [other["url"]]))])
|
||||
before = self.dials()
|
||||
unsigned = self.post_json("/api/settings", {**self.payload(streams), "proof": None}, self.guard(self.host))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"not signed: no signature\n"))
|
||||
self.assertEqual(self.dials(), before)
|
||||
self.assertEqual(self.httpd.hub.settings.get(), saved)
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ class TestHubRuleRequests(HubCase):
|
||||
with self.subTest(name=name):
|
||||
status, _, body = self.post_json(f"/api/rule-requests/{request_id}/accept", {"rule": rule, "assertion": assertion}, self.guard(self.host))
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertIn("nicht signiert", body.decode())
|
||||
self.assertIn("not signed", body.decode())
|
||||
self.assertEqual((self.waited(request_id)["status"], self.httpd.hub.rules.listed()), ("pending", []))
|
||||
self.assertIsNotNone(self.item(request_id))
|
||||
now = int(time.time())
|
||||
|
||||
@@ -35,27 +35,27 @@ class TestHubSettings(HubCase):
|
||||
def test_a_new_stream_url_needs_the_passkey(self):
|
||||
good = self.guard(self.host)
|
||||
payload = self.settings_payload(STREAMS)
|
||||
self.assertEqual(self.post_json("/api/settings", payload, good)[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", payload, good)[:3:2], (400, b"not signed: no signature\n"))
|
||||
self.assertFalse((self.state / "settings.json").exists())
|
||||
signer = self.pin()
|
||||
request = self.prepared(payload)["payload"]
|
||||
stranger = key(-7, "stranger").proof(request, self.origin)
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": stranger}, good)[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": stranger}, good)[:3:2], (400, b"not signed: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.media_src(), "media-src 'self'")
|
||||
proof = signer.proof(request, self.origin)
|
||||
status, _, body = self.post_json("/api/settings", {**payload, "proof": proof}, good)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://other.example:8443 https://radio.example")
|
||||
moved = self.settings_payload([{**STREAMS[0], "url": "https://radio.example/abend"}, STREAMS[1]])
|
||||
self.assertEqual(self.post_json("/api/settings", moved, good)[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
moved = self.settings_payload([{**STREAMS[0], "url": "https://radio.example/evening"}, STREAMS[1]])
|
||||
self.assertEqual(self.post_json("/api/settings", moved, good)[:3:2], (400, b"not signed: no signature\n"))
|
||||
status, _, body = self.save(moved, signer)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://radio.example")
|
||||
self.assertEqual(self.post_json("/api/settings", self.settings_payload([]), good)[0], 200)
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": proof}, good)[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": proof}, good)[:3:2], (400, b"not signed: this signature was already used for this item\n"))
|
||||
self.assertEqual(self.save(moved, signer)[0], 200)
|
||||
evil = self.settings_payload([*moved["streams"], {**STREAMS[0], "url": "https://evil.example/listen"}])
|
||||
self.assertEqual(self.post_json("/api/settings", {**evil, "proof": signer.proof(self.prepared(payload)["payload"], self.origin)}, good)[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", {**evil, "proof": signer.proof(self.prepared(payload)["payload"], self.origin)}, good)[:3:2], (400, b"not signed: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", evil, good)[0], 400)
|
||||
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://radio.example")
|
||||
status, _, body = self.save(evil, signer)
|
||||
@@ -163,7 +163,7 @@ class TestHubSettings(HubCase):
|
||||
|
||||
def test_csp_media_src_lists_exactly_the_stream_origins(self):
|
||||
self.assertEqual(self.media_src(), "media-src 'self'")
|
||||
streams = [*STREAMS, {**STREAMS[0], "name": "Gleich", "url": "https://Radio.Example:443/zweiter?x=1"}]
|
||||
streams = [*STREAMS, {**STREAMS[0], "name": "Same", "url": "https://Radio.Example:443/second?x=1"}]
|
||||
status, _, body = self.save(self.settings_payload(streams), self.pin())
|
||||
self.assertEqual(status, 200, body)
|
||||
origins = ["http://10.0.0.5:8000", "https://other.example:8443", "https://radio.example"]
|
||||
|
||||
@@ -57,7 +57,7 @@ const ctx = Object.fromEntries(["clearRect", "beginPath", "moveTo", "lineTo", "q
|
||||
calls.count += 1;
|
||||
}]));
|
||||
const colors = Object.fromEntries(["line", "accent", "muted", "violet", "direct", "broadcast", "alarm", "security", "grant", "panel", "text"].map((role) => [role, "#123456"]));
|
||||
const style = {colors, font: "12px sans", strong: "bold 12px sans", level: "Tiefe", cursor: "Stand", selected: "s3", format: String};
|
||||
const style = {colors, font: "12px sans", strong: "bold 12px sans", level: "Depth", cursor: "As of", selected: "s3", format: String};
|
||||
const builds = Array.from({length: 15}, () => clock(() => S.scene(input))[0]);
|
||||
let stats = null;
|
||||
const paints = Array.from({length: 30}, (_, frame) => {
|
||||
|
||||
@@ -216,7 +216,7 @@ class TestPhraseology(unittest.TestCase):
|
||||
(("mayday", "Docs", [], "MAYDAY disk full"), "MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over"),
|
||||
(("mayday", "Docs", ["Skills"], "mayday, mayday: disk full"), "MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over"),
|
||||
(("pan-pan", "Docs", ["Skills"], "PAN-PAN: slow"), "PAN-PAN PAN-PAN PAN-PAN, all stations, this is Docs, slow, over"),
|
||||
(("security", "Maintainer", ["Skills", "Docs"], "SECURITE rotate"), "SECURITE SECURITE SECURITE, all stations, this is Maintainer, rotate, over"),
|
||||
(("security", "Maintainer", ["Skills", "Docs"], "SECURITE rotate"), "say-cure-ee-tay say-cure-ee-tay say-cure-ee-tay, all stations, this is Maintainer, rotate, over"),
|
||||
(("call", "Skills", ["Maintainer"], "ready"), "Maintainer, Maintainer, this is Skills, ready, over"),
|
||||
(("post", "Skills", ["Alpha", "Beta"], "ready"), "Alpha and Beta, Alpha and Beta, this is Skills, ready, over"),
|
||||
(("call", "Docs", ["Ops"], "MAYDAY is only a word here"), "Ops, Ops, this is Docs, MAYDAY is only a word here, over"),
|
||||
@@ -230,6 +230,18 @@ class TestPhraseology(unittest.TestCase):
|
||||
with self.subTest(arguments=arguments):
|
||||
self.assertEqual(phrasing.phrase(*arguments), expected)
|
||||
|
||||
def test_hashes_spell_in_nato_and_digits_in_icao_only_when_spoken(self):
|
||||
cases = (
|
||||
("8e180be", "eight echo one eight zero bravo echo"),
|
||||
("42305", "four two tree zero fife"),
|
||||
("fix 8E180BE, PR #1326", "fix eight echo one eight zero bravo echo, PR #one tree two six"),
|
||||
("8e180b decade deadbeef", "eight e one eight zero b decade deadbeef"),
|
||||
)
|
||||
for text, expected in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(phrasing.phrase("broadcast", "Docs", [], text), f"all stations, this is Docs, {expected}, out")
|
||||
self.assertEqual(phrasing.pronounced(phrasing.pronounced("8e180be 42305")), phrasing.pronounced("8e180be 42305"))
|
||||
|
||||
def test_a_phrase_fits_the_speech_limit_on_one_line(self):
|
||||
for cls in council.CLASSES:
|
||||
with self.subTest(cls=cls):
|
||||
|
||||
@@ -162,7 +162,7 @@ class TestHubState(HubCase):
|
||||
"all stations, this is Maintainer, board, out",
|
||||
"MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over",
|
||||
"PAN-PAN PAN-PAN PAN-PAN, all stations, this is Docs, slow, over",
|
||||
"SECURITE SECURITE SECURITE, all stations, this is Maintainer, rotate, over",
|
||||
"say-cure-ee-tay say-cure-ee-tay say-cure-ee-tay, all stations, this is Maintainer, rotate, over",
|
||||
])
|
||||
speech = self.httpd.hub.speech
|
||||
self.assertEqual([row["voice"] for row in state["radio"]], [speech.voice_of(row["source"]) for row in state["radio"]])
|
||||
|
||||
@@ -149,10 +149,10 @@ class TestHubStream(HubCase):
|
||||
"Cron: 7 * * * *": (True, "Skills", "iterate"),
|
||||
"Wakeup: in 60 s": (False, "Skills", "/loop\nfirst"),
|
||||
"Wakeup: in 1200 s": (True, "Skills", "/loop\nfallback"),
|
||||
"Hintergrund: gate": (True, "Skills", "make test"),
|
||||
"Background: gate": (True, "Skills", "make test"),
|
||||
"Timer: *-*-* 08:00:00": (True, "", "Daily dream\nkvb-dream-notify.timer"),
|
||||
"Timer: 1h": (False, "", "old.timer"),
|
||||
"Queue: gate · läuft": (True, "Skills", "make test\nwork/hub"),
|
||||
"Queue: gate · running": (True, "Skills", "make test\nwork/hub"),
|
||||
})
|
||||
self.write_council({"work/s1/claim.json": {"office": "work/s1", "session": "s1", "runtime": "claude", "name": "Skills session", "since": "2026-11-05T08:00:00+00:00", "beat": now - 3600, "work": "x"}})
|
||||
self.assertEqual(crons()["Cron: 7 * * * *"][0], False)
|
||||
|
||||
@@ -118,7 +118,7 @@ class TestHubSurface(HubCase):
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_the_stream_has_four_categories_and_transcripts_stay_out_by_default(self):
|
||||
self.assertEqual({key: value["label"] for key, value in events.VIEWS.items()}, {"fuer-dich": "Für dich", "funk": "Funk", "arbeit": "Arbeit", "transkripte": "Transkripte"})
|
||||
self.assertEqual({key: value["label"] for key, value in events.VIEWS.items()}, {"fuer-dich": "For you", "funk": "Radio", "arbeit": "Work", "transkripte": "Transcripts"})
|
||||
self.assertEqual(events.VIEWS["transkripte"]["kinds"], ["transcript"])
|
||||
self.assertNotIn("transcript", events.VIEWS["fuer-dich"]["kinds"] + events.VIEWS["funk"]["kinds"] + events.VIEWS["arbeit"]["kinds"])
|
||||
self.assertEqual(sorted(events.VIEWS["fuer-dich"]["kinds"]), sorted(events.VIEWS["funk"]["kinds"] + events.VIEWS["arbeit"]["kinds"]))
|
||||
@@ -139,13 +139,13 @@ class TestHubSurface(HubCase):
|
||||
|
||||
self.assertEqual(texts("deploy"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy", "radio:Ops:deploy done", "review:feat: deploy:feat: deploy"])
|
||||
self.assertEqual(texts("agent:Docs"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy"])
|
||||
self.assertEqual(texts("art:funk deploy"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
self.assertEqual(texts("art:review"), ["review:feat: deploy:feat: deploy"])
|
||||
self.assertEqual(texts("seit:1h agent:Docs"), ["radio:Docs:fresh deploy"])
|
||||
self.assertEqual(texts("seit:5m done"), ["radio:Ops:deploy done"])
|
||||
self.assertEqual(texts("kind:radio deploy"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
self.assertEqual(texts("kind:review"), ["review:feat: deploy:feat: deploy"])
|
||||
self.assertEqual(texts("since:1h agent:Docs"), ["radio:Docs:fresh deploy"])
|
||||
self.assertEqual(texts("since:5m done"), ["radio:Ops:deploy done"])
|
||||
since = time.strftime("%Y-%m-%dT%H:%M", time.localtime(now - 600))
|
||||
self.assertEqual(texts(f"seit:{since} art:funk"), ["radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
for q in ("art:bogus", "seit:soon", "seit:5y", "agent:"):
|
||||
self.assertEqual(texts(f"since:{since} kind:Radio"), ["radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
for q in ("kind:bogus", "since:soon", "since:5y", "agent:"):
|
||||
with self.subTest(q=q):
|
||||
self.assertEqual(self.get(f"/api/events?q={quote(q)}")[0], 400)
|
||||
|
||||
|
||||
@@ -179,10 +179,10 @@ class TestHubTasks(HubCase):
|
||||
forged = {**self.signed([change(PRIO)], self.pin()), "assertion": {}}
|
||||
status, _, body = self.post_json("/api/tasks", {"items": [change(PRIO)], "proof": forged}, self.guard(self.host))
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertIn("nicht signiert", body.decode() if isinstance(body, bytes) else body)
|
||||
self.assertIn("not signed", body.decode() if isinstance(body, bytes) else body)
|
||||
snapshot = self.snapshot()
|
||||
self.assertEqual([job for job in snapshot["jobs"] if job["kind"] == "task"], [])
|
||||
self.assertNotIn("Aufgabe signiert geändert", json.dumps(snapshot, ensure_ascii=False))
|
||||
self.assertNotIn("Task change signed", json.dumps(snapshot, ensure_ascii=False))
|
||||
|
||||
def test_a_task_the_hub_cannot_sign_whole_is_not_editable(self):
|
||||
long_slug = "work/" + "x" * 600
|
||||
|
||||
Reference in new issue
Block a user