Merge feature/workbench-w6-speech-bubbles into unify/skills-20261111
This commit is contained in:
commit
08fd189c30
19 files changed
+788
-20
No files matched your search
@@ -1766,7 +1766,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
with self.subTest(url=step["url"], width=step["width"]):
|
||||
self.assertNotIn("error", probe)
|
||||
self.assertEqual((probe["title"], probe["h1"]), ("(2) Agent Orchestrator", "Agent Orchestrator"))
|
||||
self.assertEqual(probe["header"], ["radio-open", "palette-open", "settings-open"])
|
||||
self.assertEqual(probe["header"], ["radio-open", "bubbles-toggle", "palette-open", "settings-open"])
|
||||
self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS))
|
||||
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-agents": "1", "tab-artifacts": None, "tab-tasks": None})
|
||||
self.assertEqual(probe["banner"], "MAYDAY · Docs: disk full")
|
||||
@@ -1974,7 +1974,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
jump, *walked = data["results"]
|
||||
self.assertEqual(jump, {
|
||||
"hash": "#stream", "focused": True, "tabs": [[tab, True, True] for tab in ("#stream/inbox", "#agents/orbit", "#artifacts/tree", "#tasks/list")], "closed": [True, "false"],
|
||||
"header": [["radio-open", "Radio", ""], ["palette-open", "Commands", ""], ["settings-open", "Settings", ""]], "radioTab": False,
|
||||
"header": [["radio-open", "Radio", ""], ["bubbles-toggle", "Speech bubbles", ""], ["palette-open", "Commands", ""], ["settings-open", "Settings", ""]], "radioTab": False,
|
||||
})
|
||||
for index, (width, _) in enumerate(WIDTHS):
|
||||
sent, saved, read = walked[3 * index : 3 * (index + 1)]
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path[:0] = [str(HERE.parent), str(HERE)]
|
||||
|
||||
import test_orchestrator_e2e as base
|
||||
from hub_case import HubCase
|
||||
from hub_support import iso
|
||||
|
||||
OUT = Path(os.environ.get("E2E_OUT", "/tmp/workbench-w6-shots"))
|
||||
CUE = "e2e-bubbles"
|
||||
FIRST = "Disk almost full on the build host, the gate waits for space now"
|
||||
SECOND = "Gate green for the merge"
|
||||
VIEWS = {1920: "agents/orbit", 1366: "stream/radio", 390: "agents/hierarchy"}
|
||||
SECOND_FROM = {1920: "Skills session", 1366: "Skills session", 390: "Docs"}
|
||||
CALM = [{"name": "prefers-reduced-motion", "value": "reduce"}]
|
||||
GUARD = """() => {
|
||||
window.e2eViolations = [];
|
||||
document.addEventListener("securitypolicyviolation", (event) => window.e2eViolations.push(`${event.violatedDirective} ${event.blockedURI}`));
|
||||
}"""
|
||||
READ = """
|
||||
const toggle = document.getElementById("bubbles-toggle");
|
||||
const layer = document.getElementById("bubbles");
|
||||
const live = () => all(".speech-bubble:not(.leaving)", layer);
|
||||
const speaking = () => live().find((node) => node.dataset.state === "speaking") || null;
|
||||
const waiting = () => live().find((node) => node.dataset.state === "waiting") || null;
|
||||
const read = (node) => {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
const style = getComputedStyle(node);
|
||||
const box = node.getBoundingClientRect();
|
||||
return {
|
||||
who: node.dataset.who, scale: Number(node.dataset.scale), state: node.dataset.state, words: Number(node.dataset.words), text: text(node.querySelector(".bubble-words")),
|
||||
anchored: node.parentElement === layer, rendered: Math.round(new DOMMatrix(style.transform).a * 100) / 100, transition: style.transitionProperty, font: style.fontFamily,
|
||||
box: {left: box.left, top: box.top, right: box.right, bottom: box.bottom},
|
||||
};
|
||||
};
|
||||
const cue = (word) => fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: word, voice: ""}), cache: "no-store"});
|
||||
"""
|
||||
BUBBLES_ON = """(async () => {@HELPERS@@READ@
|
||||
const before = [toggle.getAttribute("aria-pressed"), layer.hidden];
|
||||
toggle.click();
|
||||
const on = Boolean(await until(() => toggle.getAttribute("aria-pressed") === "true" && !layer.hidden, 8000));
|
||||
await cue("@CUE@");
|
||||
if (!(await until(() => speaking() && waiting(), 25000))) {
|
||||
return {before, on, error: "no pair of bubbles", bubbles: live().map(read), toasts: all("#toasts .toast").map(text)};
|
||||
}
|
||||
await sleep(400);
|
||||
return {before, on, first: read(speaking()), second: read(waiting())};
|
||||
})()"""
|
||||
BUBBLES_GROW = """(async () => {@HELPERS@@READ@
|
||||
const who = speaking() ? speaking().dataset.who : null;
|
||||
const seen = [];
|
||||
let [grown, faded, gone] = [null, null, null];
|
||||
const end = Date.now() + 15000;
|
||||
while (who && !grown && Date.now() < end) {
|
||||
const now = speaking();
|
||||
if (now && now.dataset.who === who) {
|
||||
seen.push(Number(now.dataset.words));
|
||||
} else if (now) {
|
||||
faded = Boolean(layer.querySelector(`.speech-bubble.leaving[data-who="${CSS.escape(who)}"]`));
|
||||
await sleep(450);
|
||||
gone = !layer.querySelector(`.speech-bubble[data-who="${CSS.escape(who)}"]`);
|
||||
grown = read(speaking());
|
||||
}
|
||||
await sleep(40);
|
||||
}
|
||||
return {who, seen: seen.filter((value, index) => value !== seen[index - 1]), faded, gone, grown};
|
||||
})()"""
|
||||
BUBBLES_DONE = """(async () => {@HELPERS@@READ@
|
||||
const cleared = Boolean(await until(() => !layer.querySelector(".speech-bubble"), 10000));
|
||||
const faces = [...document.fonts].filter((face) => face.family.replace(/["']/g, "") === "Comic Neue").map((face) => face.status);
|
||||
return {cleared, faces, hidden: layer.hidden, violations: window.e2eViolations || null};
|
||||
})()"""
|
||||
BUBBLES_3D = """(async () => {@HELPERS@@READ@
|
||||
const canvas = Boolean(await until(() => document.querySelector("#main .graph3d canvas"), 15000));
|
||||
await sleep(1500);
|
||||
await cue("@CUE@");
|
||||
if (!(await until(speaking, 25000))) {
|
||||
return {canvas, error: "no bubble in 3D", bubbles: live().map(read)};
|
||||
}
|
||||
const first = read(speaking());
|
||||
await sleep(500);
|
||||
const later = read(speaking());
|
||||
const graph = document.querySelector("#main .graph3d").getBoundingClientRect();
|
||||
return {canvas, first, later, graph: {left: graph.left, top: graph.top, right: graph.right, bottom: graph.bottom}, mode: text(document.querySelector("#main .space-note"))};
|
||||
})()"""
|
||||
BUBBLES_KEPT = """(async () => {@HELPERS@@READ@
|
||||
await sleep(300);
|
||||
const kept = [toggle.getAttribute("aria-pressed"), layer.hidden, toggle.title, toggle.getAttribute("aria-label")];
|
||||
toggle.click();
|
||||
const off = Boolean(await until(() => toggle.getAttribute("aria-pressed") === "false" && layer.hidden, 8000));
|
||||
return {kept, off, display: getComputedStyle(layer).display, bubbles: layer.querySelectorAll(".speech-bubble").length, violations: window.e2eViolations || null};
|
||||
})()"""
|
||||
|
||||
|
||||
def probe(template: str, cue: str = CUE) -> str:
|
||||
return template.replace("@HELPERS@", base.HELPERS).replace("@READ@", READ).replace("@CUE@", cue)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("CHROMIUM", "chromium")), "no Chromium")
|
||||
class TestWorkbenchBubbles(HubCase):
|
||||
drive = base.TestOrchestratorE2E.drive
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
self.write_council({
|
||||
"work/s2/claim.json": {"office": "work/s2", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 60, "work": "x"},
|
||||
"chair/claim.json": {"office": "chair", "session": "s1", "runtime": "claude", "name": "Skills session", "since": iso(now - 7200), "beat": now, "work": "allocate"},
|
||||
"news.jsonl": self.jsonl([
|
||||
{"ts": now - 600, "from": "Skills session", "to": ["Stage"], "text": "rebase please", "class": "post", "v": 2},
|
||||
{"ts": now - 500, "from": "Stage", "to": ["Skills session"], "text": "gate red", "class": "call", "v": 2},
|
||||
{"ts": now - 400, "from": "Docs", "to": [], "text": "docs are green", "class": "newsletter", "v": 2},
|
||||
]),
|
||||
})
|
||||
self.keys = {row["from"]: row["source"] for row in self.snapshot()["radio"]}
|
||||
hub = self.httpd.hub
|
||||
spoken = base.tone(1, 8000)
|
||||
|
||||
def synthesize(body: bytes) -> bytes:
|
||||
word = json.loads(body)["text"]
|
||||
if word.startswith(CUE):
|
||||
now = time.time()
|
||||
rows = [
|
||||
{"ts": now - 0.01, "from": "Stage", "to": [], "text": FIRST, "class": "security", "v": 2},
|
||||
{"ts": now, "from": "Docs" if word.endswith("-docs") else "Skills session", "to": [], "text": SECOND, "class": "security", "v": 2},
|
||||
]
|
||||
with (self.council / "news.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write(self.jsonl(rows))
|
||||
return spoken
|
||||
|
||||
patch = mock.patch.object(hub.speech, "synthesize", synthesize)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
self.base = f"http://{self.host}/"
|
||||
|
||||
def steps(self, browser: str, calm: bool, three: bool) -> list[dict]:
|
||||
steps = []
|
||||
for width, height in base.WIDTHS:
|
||||
view = VIEWS[width]
|
||||
media = CALM if calm and width == 390 else []
|
||||
cue = f"{CUE}-docs" if SECOND_FROM[width] == "Docs" else CUE
|
||||
shot = str(OUT / f"{browser}-{width}-bubbles")
|
||||
steps += [
|
||||
{"width": width, "height": height, "url": f"{self.base}?{width}#{view}", "wait": 1200, "probe": probe(BUBBLES_ON, cue), "shot": f"{shot}-pair.png", "media": media},
|
||||
{"width": width, "height": height, "url": None, "wait": 0, "probe": probe(BUBBLES_GROW), "shot": f"{shot}-grown.png", "media": media},
|
||||
{"width": width, "height": height, "url": None, "wait": 0, "probe": probe(BUBBLES_DONE), "shot": None, "media": media},
|
||||
]
|
||||
if three and width == 1366:
|
||||
steps.append({"width": width, "height": height, "url": f"{self.base}?3d#agents/3d", "wait": 1500, "probe": probe(BUBBLES_3D), "shot": f"{shot}-3d.png"})
|
||||
steps.append({"width": width, "height": height, "url": f"{self.base}?{width}-reloaded#{view}", "wait": 1200, "probe": probe(BUBBLES_KEPT), "shot": f"{shot}-off.png", "media": media})
|
||||
return steps
|
||||
|
||||
def check(self, data: dict, browser: str, calm: bool, three: bool) -> None:
|
||||
results = iter(data["results"])
|
||||
for width, _ in base.WIDTHS:
|
||||
on, grow, done = next(results), next(results), next(results)
|
||||
solid = next(results) if three and width == 1366 else None
|
||||
kept = next(results)
|
||||
first_key, second_key = self.keys["Stage"], self.keys[SECOND_FROM[width]]
|
||||
with self.subTest(browser=browser, width=width):
|
||||
self.assertNotIn("error", on, on)
|
||||
self.assertEqual((on["before"], on["on"]), (["false", True], True))
|
||||
first, second = on["first"], on["second"]
|
||||
self.assertEqual((first["who"], first["scale"], first["rendered"], first["state"], first["anchored"]), (first_key, 1, 1, "speaking", True), first)
|
||||
self.assertTrue(0 < first["words"] < len(FIRST.split()), first)
|
||||
self.assertEqual(first["text"], " ".join(FIRST.split()[: first["words"]]))
|
||||
self.assertTrue(first["font"].startswith('"Comic Neue"'), first["font"])
|
||||
self.assertEqual((second["who"], second["scale"], second["rendered"], second["state"], second["text"], second["words"]), (second_key, 0.8, 0.8, "waiting", "…", 0), second)
|
||||
self.assertEqual(second["anchored"], SECOND_FROM[width] != "Docs", second)
|
||||
motion = "opacity" if calm and width == 390 else "transform, opacity"
|
||||
self.assertEqual((first["transition"], second["transition"]), (motion, motion))
|
||||
self.assertEqual(grow["who"], first_key)
|
||||
self.assertGreaterEqual(len(grow["seen"]), 3, grow)
|
||||
self.assertEqual(grow["seen"], sorted(grow["seen"]), grow)
|
||||
self.assertEqual(grow["seen"][-1], len(FIRST.split()), grow)
|
||||
self.assertEqual((grow["faded"], grow["gone"]), (True, True), grow)
|
||||
grown = grow["grown"]
|
||||
self.assertEqual((grown["who"], grown["scale"], grown["rendered"], grown["state"]), (second_key, 1, 1, "speaking"), grown)
|
||||
self.assertEqual(grown["text"], " ".join(SECOND.split()[: grown["words"]]))
|
||||
self.assertEqual(done, {"cleared": True, "faces": ["loaded"], "hidden": False, "violations": []})
|
||||
self.assertEqual(kept, {"kept": ["true", False, "Speech bubbles", "Speech bubbles"], "off": True, "display": "none", "bubbles": 0, "violations": []})
|
||||
if solid is not None:
|
||||
with self.subTest(browser=browser, view="3d"):
|
||||
self.assertNotIn("error", solid, solid)
|
||||
graph = solid["graph"]
|
||||
for read in (solid["first"], solid["later"]):
|
||||
self.assertEqual((read["who"], read["anchored"]), (first_key, True), solid)
|
||||
middle = (read["box"]["left"] + read["box"]["right"]) / 2
|
||||
self.assertTrue(graph["left"] <= middle <= graph["right"] and graph["top"] - 16 <= read["box"]["bottom"] <= graph["bottom"], solid)
|
||||
self.assertFalse(self.httpd.hub.settings.get()["bubbles"])
|
||||
|
||||
def test_chromium_reads_two_rows_without_tts_as_growing_bubbles_keeps_the_switch_and_hides_the_layer_at_three_widths(self):
|
||||
data = self.drive(self.steps("chromium", True, True), None, init=GUARD)
|
||||
(OUT / "chromium-bubbles.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual(data["errors"], [])
|
||||
self.check(data, "chromium", True, True)
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||
def test_firefox_reads_two_rows_without_tts_as_growing_bubbles_keeps_the_switch_and_hides_the_layer_at_three_widths(self):
|
||||
data = self.drive(self.steps("firefox", False, False), None, "bidi.mjs", os.environ.get("FIREFOX", "firefox"), init=GUARD)
|
||||
(OUT / "firefox-bubbles.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], [])
|
||||
self.check(data, "firefox", False, False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import HUB
|
||||
from kvb_orchestrator import exchange, settings, view
|
||||
|
||||
BUBBLES = HUB / "templates" / "bubbles.js"
|
||||
LAYER = HUB / "templates" / "page-bubbles.js"
|
||||
SPEECH = HUB / "templates" / "page-speech.js"
|
||||
STYLE = HUB / "templates" / "style-bubbles.css"
|
||||
FONT = HUB / "vendor" / "comic-neue" / "comic-neue-latin-400-normal.woff2"
|
||||
FONT_SHA256 = "b7753f30f8bd4925cf48a2ffe936c52ff1f16fe441f8c0245acf046296ac590f"
|
||||
HOLDER = "Copyright 2014 The Comic Neue Project Authors (https://github.com/crozynski/comicneue)"
|
||||
BUBBLES_PROBE = """
|
||||
const B = require(process.argv[1]);
|
||||
const item = (who, n) => ({who, n});
|
||||
const [a1, b1, c1, a2, b2, d1] = [item("A", 1), item("B", 1), item("C", 1), item("A", 2), item("B", 2), item("D", 1)];
|
||||
const planOf = (head, pending) => B.plan(head, pending).map((entry) => [entry.who, entry.item.n, entry.scale]);
|
||||
const speech = {classes: ["security", "post"]};
|
||||
console.log(JSON.stringify({
|
||||
scales: [0, 1, 2, 3, 4, 9].map(B.scale),
|
||||
reveals: [B.reveals(4, 2), B.reveals(5, 1), B.reveals(0, 3)],
|
||||
shown: [0, 0.49, 0.5, 1.49, 1.5, 9].map((at) => B.shown(4, 2, at)),
|
||||
reading: [0, 4, 5, 9, 180].map(B.reading),
|
||||
words: [B.words(" Disk almost\\nfull\\t now "), B.words(""), B.words(null)],
|
||||
plans: [planOf(a1, [b1, c1, a2, b2, d1]), planOf(null, [b1, a1, b2]), planOf(a1, []), planOf(null, [])],
|
||||
picked: [{class: "mayday"}, {class: "security"}, {class: "post"}, {class: "broadcast"}].map((row) => B.picked(row, speech)),
|
||||
}));
|
||||
"""
|
||||
SPEECH_PROBE = """
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const Bubbles = require(process.argv[1]);
|
||||
const code = fs.readFileSync(process.argv[2], "utf8");
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
async function failed(bubbles, item, cancel) {
|
||||
const shown = [];
|
||||
const speech = {pending: [], busy: false, current: null, loading: null, timer: 0, warned: 0};
|
||||
const fetch = async () => {
|
||||
if (cancel) {
|
||||
speech.loading = null;
|
||||
}
|
||||
return {ok: false, status: 503, text: async () => "tts down "};
|
||||
};
|
||||
vm.runInNewContext(`${code}\\nsay(item);`, {
|
||||
Bubbles, speech, fetch, item, Date, settings: {bubbles}, SPEECH_QUEUE: 9, SPEECH_QUIET: 0,
|
||||
Voice: {enqueue: (pending, next) => [...pending, next]},
|
||||
toast: (title, lines) => shown.push(`${title}: ${lines.join(" ")}`),
|
||||
context: () => ({}), drawBubbles: () => {}, drawSpeaker: () => {},
|
||||
performance: {now: () => 1000}, window: {setTimeout: () => 7, clearTimeout: () => {}},
|
||||
});
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
await settle();
|
||||
}
|
||||
return {current: speech.current === item, busy: speech.busy, timer: speech.timer, started: item.started ?? null, duration: item.duration ?? null, shown};
|
||||
}
|
||||
(async () => {
|
||||
const row = () => ({text: "t", who: "A", line: "Disk almost full", words: ["Disk", "almost", "full"]});
|
||||
console.log(JSON.stringify({
|
||||
fallback: await failed(true, row(), false),
|
||||
off: await failed(false, row(), false),
|
||||
sample: await failed(true, {text: "t", who: "Operator", line: "radio check"}, false),
|
||||
cancelled: await failed(true, row(), true),
|
||||
}));
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
class TestBubbleSchedule(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
done = subprocess.run(["node", "-e", BUBBLES_PROBE, str(BUBBLES)], capture_output=True, text=True, timeout=60, check=True)
|
||||
cls.data = json.loads(done.stdout)
|
||||
|
||||
def test_the_scale_falls_with_the_queue_position_to_at_least_a_half(self):
|
||||
self.assertEqual(self.data["scales"], [1, 0.8, 0.65, 0.5, 0.5, 0.5])
|
||||
|
||||
def test_word_i_of_n_appears_at_i_over_n_of_the_duration(self):
|
||||
self.assertEqual(self.data["reveals"], [[0, 0.5, 1, 1.5], [0, 0.2, 0.4, 0.6, 0.8], []])
|
||||
self.assertEqual(self.data["shown"], [1, 1, 2, 3, 4, 4])
|
||||
|
||||
def test_without_audio_a_row_lasts_its_reading_time_at_180_words_a_minute_and_at_least_one_and_a_half_seconds(self):
|
||||
self.assertEqual(self.data["reading"], [1.5, 1.5, 5 / 3, 3, 60])
|
||||
self.assertEqual(self.data["words"], [["Disk", "almost", "full", "now"], [], []])
|
||||
|
||||
def test_each_agent_gets_one_bubble_sized_by_its_earliest_item(self):
|
||||
self.assertEqual(self.data["plans"], [
|
||||
[["A", 1, 1], ["B", 1, 0.8], ["C", 1, 0.65], ["D", 1, 0.5]],
|
||||
[["B", 1, 0.8], ["A", 1, 0.65]],
|
||||
[["A", 1, 1]],
|
||||
[],
|
||||
])
|
||||
|
||||
def test_bubbles_follow_the_read_aloud_classes_and_mayday_always(self):
|
||||
self.assertEqual(self.data["picked"], [True, True, True, False])
|
||||
|
||||
|
||||
class TestSpeechFallback(unittest.TestCase):
|
||||
def test_a_failed_tts_request_reads_the_row_at_reading_pace_only_while_bubbles_are_on(self):
|
||||
done = subprocess.run(["node", "-e", SPEECH_PROBE, str(BUBBLES), str(SPEECH)], capture_output=True, text=True, timeout=60, check=True)
|
||||
data = json.loads(done.stdout)
|
||||
failed = ["Reading radio aloud failed: 503 tts down"]
|
||||
self.assertEqual(data["fallback"], {"current": True, "busy": True, "timer": 7, "started": 1000, "duration": 1.5, "shown": failed})
|
||||
for case in ("off", "sample", "cancelled"):
|
||||
with self.subTest(case=case):
|
||||
self.assertEqual(data[case], {"current": False, "busy": False, "timer": 0, "started": None, "duration": None, "shown": failed})
|
||||
|
||||
|
||||
class TestBubbleAssets(unittest.TestCase):
|
||||
def test_the_font_is_pinned_and_carries_the_ofl_without_a_reserved_name(self):
|
||||
data = FONT.read_bytes()
|
||||
self.assertEqual((data[:4], hashlib.sha256(data).hexdigest()), (b"wOF2", FONT_SHA256))
|
||||
licence = (FONT.parent / "LICENSE").read_text(encoding="utf-8")
|
||||
self.assertIn("SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007", licence)
|
||||
self.assertIn(HOLDER, licence)
|
||||
self.assertNotIn("with Reserved Font Name", licence)
|
||||
|
||||
def test_bubble_text_is_set_as_text_only_in_the_comic_neue_stack_and_reduced_motion_drops_the_motion(self):
|
||||
layer = LAYER.read_text(encoding="utf-8")
|
||||
for markup in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "DOMParser"):
|
||||
with self.subTest(markup=markup):
|
||||
self.assertNotIn(markup, layer)
|
||||
self.assertIn("view.words.textContent = text;", layer)
|
||||
style = STYLE.read_text(encoding="utf-8")
|
||||
self.assertIn('"Comic Neue", "Comic Sans MS", cursive', style)
|
||||
self.assertIn("pointer-events: none", style)
|
||||
calm = re.search(r"@media \(prefers-reduced-motion: reduce\) \{(.*?)\n\}", style, re.DOTALL).group(1)
|
||||
self.assertIn(".speech-bubble { transition: opacity .4s var(--ease); }", calm)
|
||||
self.assertIn(".bubble-words { scroll-behavior: auto; }", calm)
|
||||
|
||||
def test_the_schedule_module_parses_on_its_own(self):
|
||||
done = subprocess.run(["node", "--check", str(BUBBLES)], capture_output=True, text=True, timeout=60, check=False)
|
||||
self.assertEqual(done.returncode, 0, done.stderr)
|
||||
|
||||
|
||||
class TestHubBubbles(HubCase):
|
||||
def test_the_toolbar_icon_and_the_layer_ship_in_the_page_under_the_unchanged_csp(self):
|
||||
status, headers, body = self.get("/")
|
||||
self.assertEqual(status, 200)
|
||||
html = body.decode("utf-8")
|
||||
header = re.search(r'<header class="top">(.*?)</header>', html, re.DOTALL).group(1)
|
||||
toggle = re.search(r'<button [^>]*id="bubbles-toggle"[^>]*>(.*?)</button>', header).group(0)
|
||||
for part in ('title="Speech bubbles"', 'aria-label="Speech bubbles"', 'aria-pressed="false"', '<use href="#bi-chat-quote"></use>'):
|
||||
with self.subTest(part=part):
|
||||
self.assertIn(part, toggle)
|
||||
self.assertIn('<symbol id="bi-chat-quote"', html)
|
||||
self.assertIn('<div class="bubbles" id="bubbles" aria-hidden="true" hidden><div class="bubble-bar"></div></div>', html)
|
||||
self.assertIn(BUBBLES.read_text(encoding="utf-8").strip(), html)
|
||||
nonce = re.search(r"script-src 'nonce-([^']+)'", headers["content-security-policy"]).group(1)
|
||||
self.assertEqual(headers["content-security-policy"], view.INDEX_CSP.format(nonce, "'self'"))
|
||||
self.assertNotIn("font-src", headers["content-security-policy"])
|
||||
self.assertNotIn("@font-face", html)
|
||||
|
||||
def test_the_vendor_route_serves_the_font_as_woff2_under_the_plain_csp(self):
|
||||
status, headers, body = self.get("/vendor/comic-neue.woff2")
|
||||
self.assertEqual((status, headers["content-type"], headers["content-security-policy"]), (200, "font/woff2", exchange.PLAIN_CSP))
|
||||
self.assertEqual(body, FONT.read_bytes())
|
||||
for path in ("/vendor/comic-neue-latin-400-normal.woff2", "/vendor/comic-neue", "/vendor/LICENSE"):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.get(path)[0], 404)
|
||||
|
||||
def test_the_bubble_switch_is_a_hub_setting_saved_without_a_passkey_that_survives_a_reload(self):
|
||||
self.assertEqual((settings.SETTINGS["bubbles"], self.snapshot()["settings"]["bubbles"]), (False, False))
|
||||
good = self.guard(self.host)
|
||||
current = self.snapshot()["settings"]
|
||||
for wrong in ("true", 1, None, [True]):
|
||||
with self.subTest(wrong=wrong):
|
||||
self.assertEqual(self.post_json("/api/settings", {**current, "bubbles": wrong}, good)[0], 400)
|
||||
self.assertEqual(self.post_json("/api/settings", {key: value for key, value in current.items() if key != "bubbles"}, good)[0], 400)
|
||||
status, _, body = self.post_json("/api/settings/sign", {**current, "bubbles": True}, good)
|
||||
self.assertEqual((status, json.loads(body)["payload"]), (200, None))
|
||||
status, _, body = self.post_json("/api/settings", {**current, "bubbles": True, "proof": None}, good)
|
||||
self.assertEqual((status, json.loads(body)["settings"]), (200, {**current, "bubbles": True}), body)
|
||||
self.assertTrue(settings.Settings(self.state / "settings.json").get()["bubbles"])
|
||||
self.assertTrue(self.snapshot()["settings"]["bubbles"])
|
||||
older = {key: value for key, value in current.items() if key != "bubbles"}
|
||||
(self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8")
|
||||
self.assertFalse(settings.Settings(self.state / "settings.json").get()["bubbles"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,12 +12,12 @@ from kvb_orchestrator import council, events, settings, skill_scripts, view
|
||||
|
||||
|
||||
class TestHubSurface(HubCase):
|
||||
def test_the_header_holds_only_the_title_the_radio_the_palette_and_the_settings(self):
|
||||
def test_the_header_holds_only_the_title_the_radio_the_bubbles_the_palette_and_the_settings(self):
|
||||
html = self.index()
|
||||
self.assertIn("<title>Agent Orchestrator</title>", html)
|
||||
header = re.search(r'<header class="top">(.*?)</header>', html, re.DOTALL).group(1)
|
||||
self.assertIn("<h1>Agent Orchestrator</h1>", header)
|
||||
self.assertEqual(re.findall(r'<button[^>]*id="([^"]+)"', header), ["radio-open", "palette-open", "settings-open"])
|
||||
self.assertEqual(re.findall(r'<button[^>]*id="([^"]+)"', header), ["radio-open", "bubbles-toggle", "palette-open", "settings-open"])
|
||||
self.assertEqual(re.findall(r"<(?:nav|select|input|textarea)\b", header), [])
|
||||
for gone in ('id="rail"', 'id="inbox-bar"', 'id="status"', 'id="board-open"', 'id="rules-open"', 'id="timebar"'):
|
||||
with self.subTest(gone=gone):
|
||||
|
||||
Reference in new issue
Block a user