dsh-web-icon-indicator 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +88 -14
- package/README.zh.md +67 -13
- package/lib/client.js +715 -98
- package/lib/index.js +399 -48
- package/lib/types/index.d.ts +73 -4
- package/package.json +3 -3
- package/test/loader-hooks.mjs +21 -0
- package/test/stubs/dsh-settings.mjs +15 -0
- package/test/stubs/schemastery.mjs +12 -0
- package/test/verify.js +2026 -0
- package/docs/safari-favicon-research.md +0 -114
package/test/verify.js
ADDED
|
@@ -0,0 +1,2026 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency, zero-build verification for dsh-web-icon-indicator.
|
|
3
|
+
*
|
|
4
|
+
* Run: node test/verify.js (also wired as `npm test`)
|
|
5
|
+
*
|
|
6
|
+
* It exercises the REAL code in lib/index.js:
|
|
7
|
+
* - host side: imports the actual plugin through loader hooks that stub the
|
|
8
|
+
* two @deepseek-ai peer imports, drives `apply()` with a fake Cordis ctx
|
|
9
|
+
* (webServer / timer / agents / fs / settings), and asserts state-machine,
|
|
10
|
+
* aggregation (`active`), approval / asking / done-hold behavior, and the
|
|
11
|
+
* status-endpoint JSON shape via the actually-registered route handler.
|
|
12
|
+
* - browser side: extracts the INJECTED_SCRIPT template, substitutes the
|
|
13
|
+
* same placeholders the host does (__STATUS_PATH__ / __BASE_PATH__ /
|
|
14
|
+
* __CFG__), and runs it in a node:vm with DOM / fetch / rAF stubs to
|
|
15
|
+
* assert favicon frames (whale vs full-frame count block), render-key
|
|
16
|
+
* transitions, effect-driven fills (blink / rainbow) on the count block,
|
|
17
|
+
* settings sync, offline-safe poll-failure restore, and legacy-host
|
|
18
|
+
* compatibility.
|
|
19
|
+
*
|
|
20
|
+
* No npm dependencies, no build step, no framework — plain `node`.
|
|
21
|
+
*/
|
|
22
|
+
import { register } from "node:module";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
25
|
+
import vm from "node:vm";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
const REPO = new URL("../", import.meta.url);
|
|
30
|
+
const SRC = readFileSync(new URL("lib/index.js", REPO), "utf8");
|
|
31
|
+
const BASE_SVG = readFileSync(new URL("icons/base.svg", REPO), "utf8");
|
|
32
|
+
|
|
33
|
+
// ---- tiny assert harness ---------------------------------------------------
|
|
34
|
+
let passed = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
const failures = [];
|
|
37
|
+
function check(name, okFlag, detail) {
|
|
38
|
+
if (okFlag) passed++;
|
|
39
|
+
else {
|
|
40
|
+
failed++;
|
|
41
|
+
failures.push(name + (detail !== undefined ? " — " + detail : ""));
|
|
42
|
+
}
|
|
43
|
+
console.log((okFlag ? "PASS" : "FAIL") + " " + name);
|
|
44
|
+
}
|
|
45
|
+
function eq(name, actual, expected) {
|
|
46
|
+
const a = JSON.stringify(actual);
|
|
47
|
+
const e = JSON.stringify(expected);
|
|
48
|
+
check(name, a === e, "got " + a + ", want " + e);
|
|
49
|
+
}
|
|
50
|
+
function ok(name, cond, detail) {
|
|
51
|
+
check(name, !!cond, detail);
|
|
52
|
+
}
|
|
53
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
54
|
+
|
|
55
|
+
// ---- source extraction helpers ---------------------------------------------
|
|
56
|
+
function extractBlock(src, marker) {
|
|
57
|
+
const start = src.indexOf(marker);
|
|
58
|
+
if (start === -1) throw new Error("marker not found: " + marker);
|
|
59
|
+
const open = src.indexOf("{", start);
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let i = open;
|
|
62
|
+
for (; i < src.length; i++) {
|
|
63
|
+
const c = src[i];
|
|
64
|
+
if (c === "{") depth++;
|
|
65
|
+
else if (c === "}") {
|
|
66
|
+
depth--;
|
|
67
|
+
if (depth === 0) break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (i >= src.length) throw new Error("unbalanced block for " + marker);
|
|
71
|
+
return src.slice(start, i + 1);
|
|
72
|
+
}
|
|
73
|
+
function extractTemplate(src) {
|
|
74
|
+
const a = src.indexOf("const INJECTED_SCRIPT = `") + "const INJECTED_SCRIPT = `".length;
|
|
75
|
+
const b = src.indexOf("`;", a);
|
|
76
|
+
if (a === -1 || b === -1) throw new Error("INJECTED_SCRIPT markers not found");
|
|
77
|
+
return src.slice(a, b);
|
|
78
|
+
}
|
|
79
|
+
const DEFAULT_STATES = {
|
|
80
|
+
idle: { effect: "static", colors: ["#1a1a1a"] },
|
|
81
|
+
running: { effect: "static", colors: ["#FACC15"] },
|
|
82
|
+
asking: { effect: "blink", colors: ["#E5484D", "#FACC15"], speed: 400 },
|
|
83
|
+
done: { effect: "static", colors: ["#22A06B"] },
|
|
84
|
+
};
|
|
85
|
+
const TEMPLATE = extractTemplate(SRC);
|
|
86
|
+
// Build the injected script exactly like the host's buildScript() does.
|
|
87
|
+
function buildScript(states) {
|
|
88
|
+
return TEMPLATE
|
|
89
|
+
.replace("__STATUS_PATH__", "/dsh-web-icon-status.json")
|
|
90
|
+
.replace("__BASE_PATH__", "/dsh-web-icon-indicator/base.svg")
|
|
91
|
+
.replace("__CFG__", JSON.stringify({ states }));
|
|
92
|
+
}
|
|
93
|
+
const builtScript = buildScript(DEFAULT_STATES);
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// PART 1 — host plugin integration (REAL apply(), stubbed ctx)
|
|
97
|
+
// ============================================================================
|
|
98
|
+
function makeCtx(opts = {}) {
|
|
99
|
+
const routes = [];
|
|
100
|
+
const taps = [];
|
|
101
|
+
const onHandlers = {};
|
|
102
|
+
const timers = [];
|
|
103
|
+
let agentsList = [];
|
|
104
|
+
// Settings service stub: mirrors the DSH 0.1.2 `settings.installSection`
|
|
105
|
+
// contract. The plugin reaches it through `ctx.inject(["settings"], cb)` (never
|
|
106
|
+
// imports `@deepseek-ai/dsh-settings`), so the captured shape lives here so the
|
|
107
|
+
// state-machine + settings-wiring assertions below can drive setSource/onChange.
|
|
108
|
+
const settings = {
|
|
109
|
+
installSection(_owner, ns, schema, entry, opts) {
|
|
110
|
+
globalThis.__DSH_ICON_TEST__ = { ns, schema, entry, settingsOpts: opts };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const ctx = {
|
|
114
|
+
get: (k) => (k === "settings" ? settings : k === "config" ? {} : undefined),
|
|
115
|
+
// Mirror the host's `ctx.inject(["settings"], cb)` pattern: run the callback
|
|
116
|
+
// with a settingsCtx exposing the stub provider.
|
|
117
|
+
inject: (deps, cb) => {
|
|
118
|
+
if (Array.isArray(deps) && deps.includes("settings")) cb({ settings });
|
|
119
|
+
},
|
|
120
|
+
webServer: {
|
|
121
|
+
register: (r) => routes.push(r),
|
|
122
|
+
tapIndex: (fn) => taps.push(fn),
|
|
123
|
+
},
|
|
124
|
+
timer: {
|
|
125
|
+
timeout: (cb, ms) => {
|
|
126
|
+
const t = { cb, ms, fired: false, ran: false, running: false };
|
|
127
|
+
t.cb = () => {
|
|
128
|
+
t.ran = true; // executed (vs cancelled via the disposer -> fired)
|
|
129
|
+
t.running = true;
|
|
130
|
+
try { return cb(); } finally { t.running = false; }
|
|
131
|
+
};
|
|
132
|
+
// The disposer mirrors the real `ctx.timer.timeout` return value; the
|
|
133
|
+
// `__test` back-reference lets assertions read the timer's own flags.
|
|
134
|
+
const disposer = () => { t.fired = true; };
|
|
135
|
+
disposer.running = () => t.running;
|
|
136
|
+
disposer.__test = t;
|
|
137
|
+
t.disposer = disposer;
|
|
138
|
+
timers.push(t);
|
|
139
|
+
return disposer;
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
on: (ev, fn) => {
|
|
143
|
+
(onHandlers[ev] ||= []).push(fn);
|
|
144
|
+
},
|
|
145
|
+
effect: (fn) => fn(), // run immediately so routes register
|
|
146
|
+
agents: {
|
|
147
|
+
list: () => agentsList.slice(),
|
|
148
|
+
get: (id) => agentsList.find((a) => a.id === id) || null,
|
|
149
|
+
},
|
|
150
|
+
fs: null,
|
|
151
|
+
sandboxPolicy: null,
|
|
152
|
+
};
|
|
153
|
+
if (opts.logger) {
|
|
154
|
+
// Optional capture of `ctx.logger(name).warn(...)`: the host half reports its
|
|
155
|
+
// default-color warnings through the cordis core logger when one is present.
|
|
156
|
+
ctx.logger = (name) => ({ warn: (msg) => opts.logger.push(name + ": " + msg) });
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ctx,
|
|
160
|
+
routes,
|
|
161
|
+
taps,
|
|
162
|
+
onHandlers,
|
|
163
|
+
timers,
|
|
164
|
+
setAgents: (list) => {
|
|
165
|
+
agentsList = list;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function fakeRes() {
|
|
170
|
+
return {
|
|
171
|
+
statusCode: 0,
|
|
172
|
+
headers: {},
|
|
173
|
+
body: null,
|
|
174
|
+
setHeader(k, v) {
|
|
175
|
+
this.headers[k] = v;
|
|
176
|
+
},
|
|
177
|
+
end(b) {
|
|
178
|
+
this.body = b;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function emit(handlers, ev, ...args) {
|
|
183
|
+
for (const fn of handlers[ev] || []) fn(...args);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log("\n=== Part 1: host plugin integration ===");
|
|
187
|
+
|
|
188
|
+
// Register the loader hook BEFORE importing the real plugin.
|
|
189
|
+
register(new URL("./loader-hooks.mjs", import.meta.url), import.meta.url);
|
|
190
|
+
const { default: plugin } = await import(new URL("../lib/index.js", import.meta.url));
|
|
191
|
+
|
|
192
|
+
{
|
|
193
|
+
// H1 contract shape
|
|
194
|
+
ok("H1 plugin contract shape", plugin && plugin.name === "dsh-web-icon-indicator",
|
|
195
|
+
"name=" + plugin?.name);
|
|
196
|
+
eq("H1b inject list", plugin.inject, ["webServer", "timer", "agents", "fs"]);
|
|
197
|
+
ok("H1c config defaults present",
|
|
198
|
+
plugin.config.askingHoldMs === 3500 && plugin.config.doneHoldMs === 5000 &&
|
|
199
|
+
plugin.config.states?.running?.colors?.[0] === "#FACC15",
|
|
200
|
+
JSON.stringify(plugin.config));
|
|
201
|
+
ok("H1d SETTINGS_NAMESPACE", plugin.SETTINGS_NAMESPACE === "web-icon-indicator");
|
|
202
|
+
ok("H1e CONFIG_SCHEMA defined", !!plugin.CONFIG_SCHEMA);
|
|
203
|
+
// The route paths are registration-time only: the settings schema must not
|
|
204
|
+
// advertise them, or a settings-document edit would point the browser at a
|
|
205
|
+
// path the server never serves.
|
|
206
|
+
const schemaSrc = SRC.slice(SRC.indexOf("const CONFIG_SCHEMA"), SRC.indexOf("/** Browser script injected"));
|
|
207
|
+
ok("H1e2 settings schema excludes the registration-time route paths",
|
|
208
|
+
!/\bstatusPath\s*:/.test(schemaSrc) && !/\biconPathPrefix\s*:/.test(schemaSrc),
|
|
209
|
+
schemaSrc.slice(0, 120));
|
|
210
|
+
ok("H1e3 settings schema covers the live keys",
|
|
211
|
+
/askingHoldMs\s*:/.test(schemaSrc) && /doneHoldMs\s*:/.test(schemaSrc) &&
|
|
212
|
+
/iconsDir\s*:/.test(schemaSrc) && /states\s*:/.test(schemaSrc));
|
|
213
|
+
|
|
214
|
+
const h = makeCtx();
|
|
215
|
+
plugin.apply(h.ctx);
|
|
216
|
+
const statusRoute = h.routes.find((r) => r.kind === "exact");
|
|
217
|
+
const baseRoute = h.routes.find((r) => r.kind === "prefix");
|
|
218
|
+
ok("H1f status route registered", !!statusRoute && statusRoute.path === "/dsh-web-icon-status.json");
|
|
219
|
+
ok("H1g base route registered", !!baseRoute && baseRoute.path === "/dsh-web-icon-indicator");
|
|
220
|
+
ok("H1h tapIndex registered", h.taps.length === 1);
|
|
221
|
+
ok("H1i settings section captured",
|
|
222
|
+
globalThis.__DSH_ICON_TEST__?.ns === "web-icon-indicator" &&
|
|
223
|
+
typeof globalThis.__DSH_ICON_TEST__?.settingsOpts?.setSource === "function" &&
|
|
224
|
+
typeof globalThis.__DSH_ICON_TEST__?.settingsOpts?.onChange === "function");
|
|
225
|
+
|
|
226
|
+
const aggregate = () => {
|
|
227
|
+
const res = fakeRes();
|
|
228
|
+
statusRoute.handler(null, res);
|
|
229
|
+
return JSON.parse(res.body);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// H2 no agents
|
|
233
|
+
const idleAgg = aggregate();
|
|
234
|
+
eq("H2 no agents -> idle active 0",
|
|
235
|
+
{ state: idleAgg.state, active: idleAgg.active, sinceIsNumber: typeof idleAgg.since === "number" },
|
|
236
|
+
{ state: "idle", active: 0, sinceIsNumber: true });
|
|
237
|
+
ok("H2b states echoed with 4 states", Object.keys(aggregate().states).length === 4);
|
|
238
|
+
|
|
239
|
+
// H3 one running
|
|
240
|
+
h.setAgents([{ id: "A", status: "running" }]);
|
|
241
|
+
let agg = aggregate();
|
|
242
|
+
eq("H3 one running -> running active 1",
|
|
243
|
+
{ state: agg.state, active: agg.active }, { state: "running", active: 1 });
|
|
244
|
+
|
|
245
|
+
// H4 two running
|
|
246
|
+
h.setAgents([
|
|
247
|
+
{ id: "A", status: "running" },
|
|
248
|
+
{ id: "B", status: "running" },
|
|
249
|
+
]);
|
|
250
|
+
agg = aggregate();
|
|
251
|
+
eq("H4 two running -> running active 2",
|
|
252
|
+
{ state: agg.state, active: agg.active }, { state: "running", active: 2 });
|
|
253
|
+
|
|
254
|
+
// H5 pending approval (hasPendingApproval real fold) raises priority + counts
|
|
255
|
+
// The stub mirrors the real contract: events carry a `seq` and
|
|
256
|
+
// `snapshotEvents(fromSeq)` returns only the delta from that offset, so the
|
|
257
|
+
// incremental cursor is exercised rather than a full re-read.
|
|
258
|
+
const approvalLog = (events) => {
|
|
259
|
+
const log = events.map((ev, i) => ({ ...ev, seq: i }));
|
|
260
|
+
return {
|
|
261
|
+
log,
|
|
262
|
+
reads: 0,
|
|
263
|
+
session: {
|
|
264
|
+
snapshotEvents: (fromSeq) => {
|
|
265
|
+
const start = typeof fromSeq === "number" ? fromSeq : 0;
|
|
266
|
+
return log.filter((ev) => ev.seq >= start);
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
const pendingLog = approvalLog([{ type: "approval/asked", data: { id: "p1" } }]);
|
|
272
|
+
h.setAgents([
|
|
273
|
+
{ id: "A", status: "running", session: pendingLog.session },
|
|
274
|
+
{ id: "B", status: "running" },
|
|
275
|
+
]);
|
|
276
|
+
agg = aggregate();
|
|
277
|
+
eq("H5 approval-asked + running -> asking active 2",
|
|
278
|
+
{ state: agg.state, active: agg.active }, { state: "asking", active: 2 });
|
|
279
|
+
|
|
280
|
+
// H6 approval/decided clears the pin
|
|
281
|
+
const decidedLog = approvalLog([
|
|
282
|
+
{ type: "approval/asked", data: { id: "p1" } },
|
|
283
|
+
{ type: "approval/decided", data: { id: "p1" } },
|
|
284
|
+
]);
|
|
285
|
+
h.setAgents([
|
|
286
|
+
{ id: "A", status: "running", session: decidedLog.session },
|
|
287
|
+
{ id: "B", status: "running" },
|
|
288
|
+
]);
|
|
289
|
+
agg = aggregate();
|
|
290
|
+
eq("H6 approval decided -> running active 2",
|
|
291
|
+
{ state: agg.state, active: agg.active }, { state: "running", active: 2 });
|
|
292
|
+
|
|
293
|
+
// H6b the fold is incremental: the second poll must ask for the delta only,
|
|
294
|
+
// and a later `approval/asked` appended to the log must still pin the agent.
|
|
295
|
+
// Uses a fresh agent id: the cursor is per agent, and "A" already folded a
|
|
296
|
+
// different log above.
|
|
297
|
+
const growing = [];
|
|
298
|
+
const readRanges = [];
|
|
299
|
+
h.setAgents([
|
|
300
|
+
{
|
|
301
|
+
id: "G",
|
|
302
|
+
status: "running",
|
|
303
|
+
session: {
|
|
304
|
+
snapshotEvents: (fromSeq) => {
|
|
305
|
+
const start = typeof fromSeq === "number" ? fromSeq : 0;
|
|
306
|
+
readRanges.push(start);
|
|
307
|
+
return growing.filter((ev) => ev.seq >= start);
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
]);
|
|
312
|
+
growing.push({ type: "approval/asked", data: { id: "p1" }, seq: 0 });
|
|
313
|
+
aggregate();
|
|
314
|
+
eq("H6b first read starts at 0", readRanges[0], 0);
|
|
315
|
+
aggregate();
|
|
316
|
+
eq("H6c second read starts past the seen event", readRanges[1], 1);
|
|
317
|
+
growing.push({ type: "approval/decided", data: { id: "p1" }, seq: 1 });
|
|
318
|
+
agg = aggregate();
|
|
319
|
+
eq("H6d delta-only fold clears the pin", agg.state, "running");
|
|
320
|
+
growing.push({ type: "approval/asked", data: { id: "p2" }, seq: 2 });
|
|
321
|
+
agg = aggregate();
|
|
322
|
+
eq("H6e appended ask still pins", agg.state, "asking");
|
|
323
|
+
|
|
324
|
+
// H7 session/event handler pinning (approval/asked event drives setState)
|
|
325
|
+
h.setAgents([{ id: "A", status: "running" }]);
|
|
326
|
+
agg = aggregate();
|
|
327
|
+
eq("H7a before event -> running active 1",
|
|
328
|
+
{ state: agg.state, active: agg.active }, { state: "running", active: 1 });
|
|
329
|
+
emit(h.onHandlers, "session/event", { id: "A" }, { type: "approval/asked" });
|
|
330
|
+
agg = aggregate();
|
|
331
|
+
eq("H7b approval/asked event -> asking active 1",
|
|
332
|
+
{ state: agg.state, active: agg.active }, { state: "asking", active: 1 });
|
|
333
|
+
emit(h.onHandlers, "session/event", { id: "A" }, { type: "approval/decided" });
|
|
334
|
+
agg = aggregate();
|
|
335
|
+
eq("H7c approval/decided event -> running active 1",
|
|
336
|
+
{ state: agg.state, active: agg.active }, { state: "running", active: 1 });
|
|
337
|
+
|
|
338
|
+
// H8 reconcile: running -> idle lands in done hold, counts as active, then expires
|
|
339
|
+
h.setAgents([{ id: "A", status: "running" }]);
|
|
340
|
+
aggregate(); // record lastSeen running
|
|
341
|
+
h.setAgents([{ id: "A", status: "idle" }]);
|
|
342
|
+
agg = aggregate(); // reconcile flips to done
|
|
343
|
+
eq("H8a reconcile running->idle -> done active 1",
|
|
344
|
+
{ state: agg.state, active: agg.active }, { state: "done", active: 1 });
|
|
345
|
+
const doneTimer = h.timers.find((t) => !t.fired && t.cb);
|
|
346
|
+
ok("H8b done hold timer scheduled", !!doneTimer);
|
|
347
|
+
doneTimer.cb(); // expire the hold
|
|
348
|
+
agg = aggregate();
|
|
349
|
+
eq("H8c done hold expired -> idle active 0",
|
|
350
|
+
{ state: agg.state, active: agg.active }, { state: "idle", active: 0 });
|
|
351
|
+
|
|
352
|
+
// H9 done-hold + another running -> running active 2 (done counts once)
|
|
353
|
+
h.setAgents([
|
|
354
|
+
{ id: "A", status: "running" },
|
|
355
|
+
{ id: "B", status: "running" },
|
|
356
|
+
]);
|
|
357
|
+
aggregate();
|
|
358
|
+
h.setAgents([
|
|
359
|
+
{ id: "A", status: "idle" },
|
|
360
|
+
{ id: "B", status: "running" },
|
|
361
|
+
]);
|
|
362
|
+
agg = aggregate();
|
|
363
|
+
eq("H9 done-hold + running -> running active 2",
|
|
364
|
+
{ state: agg.state, active: agg.active }, { state: "running", active: 2 });
|
|
365
|
+
|
|
366
|
+
// H10 ask_user_question pin: pre-execute -> asking, result -> hold, expiry -> live state
|
|
367
|
+
h.setAgents([{ id: "A", status: "running" }]);
|
|
368
|
+
emit(h.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "A" } }, () => {});
|
|
369
|
+
agg = aggregate();
|
|
370
|
+
eq("H10a ask pre-execute -> asking active 1",
|
|
371
|
+
{ state: agg.state, active: agg.active }, { state: "asking", active: 1 });
|
|
372
|
+
emit(h.onHandlers, "tools/result", { name: "ask_user_question", agent: { id: "A" } }, {});
|
|
373
|
+
const askTimer = h.timers[h.timers.length - 1];
|
|
374
|
+
ok("H10b ask hold timer scheduled", !!askTimer);
|
|
375
|
+
askTimer.cb(); // hold expired, answer already returned
|
|
376
|
+
agg = aggregate();
|
|
377
|
+
eq("H10c ask hold expired -> live running active 1",
|
|
378
|
+
{ state: agg.state, active: agg.active }, { state: "running", active: 1 });
|
|
379
|
+
|
|
380
|
+
// H11 agent/disposed cleanup
|
|
381
|
+
emit(h.onHandlers, "agent/disposed", { agent: { id: "A" } });
|
|
382
|
+
h.setAgents([]);
|
|
383
|
+
agg = aggregate();
|
|
384
|
+
eq("H11 disposed -> idle active 0", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
|
|
385
|
+
|
|
386
|
+
// H12 status endpoint HTTP shape (headers + cache-control)
|
|
387
|
+
const res = fakeRes();
|
|
388
|
+
statusRoute.handler(null, res);
|
|
389
|
+
ok("H12 status 200 + json + no-store",
|
|
390
|
+
res.statusCode === 200 &&
|
|
391
|
+
res.headers["Content-Type"] === "application/json; charset=utf-8" &&
|
|
392
|
+
res.headers["Cache-Control"] === "no-store");
|
|
393
|
+
|
|
394
|
+
// H13 base route guards the filename regex
|
|
395
|
+
const bad = fakeRes();
|
|
396
|
+
baseRoute.handler({ url: "/dsh-web-icon-indicator/other.svg" }, bad);
|
|
397
|
+
ok("H13 base route rejects non-base.svg", bad.statusCode === 404);
|
|
398
|
+
|
|
399
|
+
// H14 settings onChange re-resolves; new values reach route + host timings
|
|
400
|
+
const opts = globalThis.__DSH_ICON_TEST__.settingsOpts;
|
|
401
|
+
opts.setSource(() => ({ askingHoldMs: 100, states: { running: { effect: "breath", colors: ["#01ABCD"] } } }));
|
|
402
|
+
opts.onChange();
|
|
403
|
+
agg = aggregate();
|
|
404
|
+
eq("H14a onChange propagates new running color", agg.states.running?.colors?.[0], "#01ABCD");
|
|
405
|
+
ok("H14b askingHoldMs re-resolved to 100", plugin.config.askingHoldMs === 3500); // plugin.config is static; cfg mutated internally
|
|
406
|
+
h.setAgents([{ id: "B", status: "running" }]);
|
|
407
|
+
emit(h.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "B" } }, () => {});
|
|
408
|
+
const askTimer2 = h.timers[h.timers.length - 1];
|
|
409
|
+
ok("H14c ask hold timer uses new 100ms", askTimer2.ms === 100, "ms=" + askTimer2.ms);
|
|
410
|
+
|
|
411
|
+
// H14d/H14e an `undefined` value in the settings source must not shadow a
|
|
412
|
+
// DEFAULTS entry, and a settings edit must not move the baked route paths.
|
|
413
|
+
opts.setSource(() => ({ askingHoldMs: undefined, iconsDir: undefined, statusPath: "/ignored.json" }));
|
|
414
|
+
opts.onChange();
|
|
415
|
+
const res14 = fakeRes();
|
|
416
|
+
statusRoute.handler(null, res14);
|
|
417
|
+
const agg14 = JSON.parse(res14.body);
|
|
418
|
+
ok("H14d resolved config still serves all four default states", Object.keys(agg14.states).length === 4,
|
|
419
|
+
JSON.stringify(Object.keys(agg14.states)));
|
|
420
|
+
ok("H14e status route path unchanged by settings", statusRoute.path === "/dsh-web-icon-status.json");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
{
|
|
424
|
+
// H15–H24 — event-driven paths surfaced by the standalone audit:
|
|
425
|
+
// agent/status branches, agent/turn-stopping (+asking-pin guard), the
|
|
426
|
+
// ask re-arm cap (lost tools/result fallback), and single done-hold
|
|
427
|
+
// management (round-trip re-arm, dispose cancellation).
|
|
428
|
+
const h2 = makeCtx();
|
|
429
|
+
plugin.apply(h2.ctx);
|
|
430
|
+
const statusRoute2 = h2.routes.find((r) => r.kind === "exact");
|
|
431
|
+
const aggregate2 = () => {
|
|
432
|
+
const res = fakeRes();
|
|
433
|
+
statusRoute2.handler(null, res);
|
|
434
|
+
return JSON.parse(res.body);
|
|
435
|
+
};
|
|
436
|
+
const liveTimers = () => h2.timers.filter((t) => !t.fired && !t.ran);
|
|
437
|
+
// H15 agent/status running (event-driven) -> running
|
|
438
|
+
h2.setAgents([{ id: "A", status: "running" }]);
|
|
439
|
+
emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "running" });
|
|
440
|
+
let agg = aggregate2();
|
|
441
|
+
eq("H15 agent/status running -> running active 1",
|
|
442
|
+
{ state: agg.state, active: agg.active }, { state: "running", active: 1 });
|
|
443
|
+
|
|
444
|
+
// H16 running event while the asking pin is on -> pin wins
|
|
445
|
+
emit(h2.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "A" } }, () => {});
|
|
446
|
+
emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "running" });
|
|
447
|
+
agg = aggregate2();
|
|
448
|
+
eq("H16 running event under asking pin -> asking",
|
|
449
|
+
{ state: agg.state, active: agg.active }, { state: "asking", active: 1 });
|
|
450
|
+
|
|
451
|
+
// H17 agent/status idle after running -> done hold (event-driven), then expiry
|
|
452
|
+
// H17 agent/status idle after running -> done hold (event-driven), then expiry.
|
|
453
|
+
// The event and the live agents list must agree: aggregations read the list
|
|
454
|
+
// status first, so the idle event is paired with list status "idle".
|
|
455
|
+
emit(h2.onHandlers, "tools/result", { name: "ask_user_question", agent: { id: "A" } }, {});
|
|
456
|
+
const askT = h2.timers[h2.timers.length - 1];
|
|
457
|
+
askT.cb(); // hold expired, result present -> back to live running
|
|
458
|
+
agg = aggregate2();
|
|
459
|
+
eq("H17a ask released -> running", { state: agg.state, active: agg.active }, { state: "running", active: 1 });
|
|
460
|
+
h2.setAgents([{ id: "A", status: "idle" }]);
|
|
461
|
+
emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "idle" });
|
|
462
|
+
agg = aggregate2();
|
|
463
|
+
eq("H17b idle event (prev running) -> done hold",
|
|
464
|
+
{ state: agg.state, active: agg.active }, { state: "done", active: 1 });
|
|
465
|
+
const dt = liveTimers().at(-1);
|
|
466
|
+
dt.cb();
|
|
467
|
+
agg = aggregate2();
|
|
468
|
+
eq("H17c done hold expires -> idle", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
|
|
469
|
+
|
|
470
|
+
// H18 idle event on an idle agent -> stays idle
|
|
471
|
+
emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "idle" });
|
|
472
|
+
agg = aggregate2();
|
|
473
|
+
eq("H18 idle event on idle -> idle", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
|
|
474
|
+
|
|
475
|
+
// H19 agent/turn-stopping (running) -> done (live list follows to "idle",
|
|
476
|
+
// as in reality the stopping turn syncs to idle before the next aggregation)
|
|
477
|
+
h2.setAgents([{ id: "A", status: "running" }]);
|
|
478
|
+
emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "running" });
|
|
479
|
+
emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "A" } });
|
|
480
|
+
h2.setAgents([{ id: "A", status: "idle" }]);
|
|
481
|
+
agg = aggregate2();
|
|
482
|
+
eq("H19 turn-stopping (running) -> done",
|
|
483
|
+
{ state: agg.state, active: agg.active }, { state: "done", active: 1 });
|
|
484
|
+
|
|
485
|
+
// H20 turn-stopping while the asking pin is on -> guard keeps asking
|
|
486
|
+
emit(h2.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "A" } }, () => {});
|
|
487
|
+
emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "A" } });
|
|
488
|
+
agg = aggregate2();
|
|
489
|
+
eq("H20 turn-stopping under pin -> stays asking",
|
|
490
|
+
{ state: agg.state, active: agg.active }, { state: "asking", active: 1 });
|
|
491
|
+
|
|
492
|
+
// H21 lost tools/result: re-arms while the agent stays running (the user may
|
|
493
|
+
// still be deciding), force-releases only once the agent leaves `running`.
|
|
494
|
+
emit(h2.onHandlers, "agent/disposed", { agent: { id: "A" } });
|
|
495
|
+
h2.setAgents([{ id: "B", status: "running" }]);
|
|
496
|
+
emit(h2.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "B" } }, () => {});
|
|
497
|
+
agg = aggregate2();
|
|
498
|
+
eq("H21a ask pinned", { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
|
|
499
|
+
for (let i = 1; i <= 5; i++) {
|
|
500
|
+
h2.timers[h2.timers.length - 1].cb(); // re-arm (no result yet, still running)
|
|
501
|
+
agg = aggregate2();
|
|
502
|
+
eq("H21b re-arm #" + i + " (no result, still running) -> still asking",
|
|
503
|
+
{ state: agg.state, active: agg.active }, { state: "asking", active: 1 });
|
|
504
|
+
}
|
|
505
|
+
// The turn ended while the result never arrived: the agent left `running`,
|
|
506
|
+
// so the pin must force-release instead of re-arming forever. The release is
|
|
507
|
+
// not a silent switch to idle — the running→idle transition is the standard
|
|
508
|
+
// turn-end path, so reconcile arms the normal done hold first, then idle.
|
|
509
|
+
h2.setAgents([{ id: "B", status: "idle" }]);
|
|
510
|
+
h2.timers[h2.timers.length - 1].cb(); // hold expired, agent no longer running
|
|
511
|
+
agg = aggregate2();
|
|
512
|
+
eq("H21c force-release after agent leaves running -> done hold",
|
|
513
|
+
{ state: agg.state, active: agg.active }, { state: "done", active: 1 });
|
|
514
|
+
liveTimers().at(-1).cb(); // done hold expires
|
|
515
|
+
agg = aggregate2();
|
|
516
|
+
eq("H21d done hold expiry -> idle", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
|
|
517
|
+
|
|
518
|
+
// H22 done->running->done round-trip keeps a SINGLE live hold
|
|
519
|
+
h2.setAgents([{ id: "C", status: "running" }]);
|
|
520
|
+
emit(h2.onHandlers, "agent/status", { agent: { id: "C" }, status: "running" });
|
|
521
|
+
emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "C" } }); // -> done (hold 1)
|
|
522
|
+
emit(h2.onHandlers, "agent/status", { agent: { id: "C" }, status: "running" }); // -> running (cancels hold 1)
|
|
523
|
+
emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "C" } }); // -> done (hold 2)
|
|
524
|
+
const doneLives = liveTimers();
|
|
525
|
+
eq("H22a only one live done hold after round-trip", doneLives.length, 1);
|
|
526
|
+
h2.setAgents([{ id: "C", status: "idle" }]); // list agrees: turn finished
|
|
527
|
+
doneLives[0].cb();
|
|
528
|
+
agg = aggregate2();
|
|
529
|
+
eq("H22b live hold expiry -> idle",
|
|
530
|
+
{ state: agg.state, active: agg.active }, { state: "idle", active: 0 });
|
|
531
|
+
|
|
532
|
+
// H23 dispose cancels an in-flight done hold
|
|
533
|
+
h2.setAgents([{ id: "D", status: "running" }]);
|
|
534
|
+
emit(h2.onHandlers, "agent/status", { agent: { id: "D" }, status: "running" });
|
|
535
|
+
emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "D" } }); // done + hold
|
|
536
|
+
const dHold = liveTimers();
|
|
537
|
+
ok("H23a done hold armed", dHold.length === 1, "live=" + dHold.length);
|
|
538
|
+
emit(h2.onHandlers, "agent/disposed", { agent: { id: "D" } });
|
|
539
|
+
ok("H23b disposed cancels done hold", dHold[0].fired === true);
|
|
540
|
+
h2.setAgents([]);
|
|
541
|
+
agg = aggregate2();
|
|
542
|
+
eq("H23c disposed -> idle active 0", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
{
|
|
546
|
+
// H24 the asking pin must still be releasable after a re-arm followed by a
|
|
547
|
+
// second pre-execute. Regression: scheduleAskCheck cancelled the stored handle
|
|
548
|
+
// unconditionally, so a pre-execute arriving while the callback was executing
|
|
549
|
+
// its re-arm path killed the fresh handle — the session stayed pinned to
|
|
550
|
+
// `asking` forever (nothing left to release it).
|
|
551
|
+
const h3 = makeCtx();
|
|
552
|
+
plugin.apply(h3.ctx);
|
|
553
|
+
const statusRoute3 = h3.routes.find((r) => r.kind === "exact");
|
|
554
|
+
const aggregate3 = () => {
|
|
555
|
+
const res = fakeRes();
|
|
556
|
+
statusRoute3.handler(null, res);
|
|
557
|
+
return JSON.parse(res.body);
|
|
558
|
+
};
|
|
559
|
+
const state3 = () => { const a = aggregate3(); return { state: a.state, active: a.active }; };
|
|
560
|
+
|
|
561
|
+
h3.setAgents([{ id: "E", status: "running" }]);
|
|
562
|
+
emit(h3.onHandlers, "agent/status", { agent: { id: "E" }, status: "running" });
|
|
563
|
+
emit(h3.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "E" } }, () => {});
|
|
564
|
+
eq("H24a ask pinned", state3(), { state: "asking", active: 1 });
|
|
565
|
+
|
|
566
|
+
h3.timers.at(-1).cb(); // no result yet + still running -> re-arm
|
|
567
|
+
eq("H24b still asking after re-arm", state3(), { state: "asking", active: 1 });
|
|
568
|
+
|
|
569
|
+
// Second ask_user_question while the re-armed timer is pending.
|
|
570
|
+
emit(h3.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "E" } }, () => {});
|
|
571
|
+
eq("H24c still asking after second pre-execute", state3(), { state: "asking", active: 1 });
|
|
572
|
+
|
|
573
|
+
// The tool finally returns: SOME pending timer must be able to release the
|
|
574
|
+
// pin. Every handle created for this agent is exercised, so a fix that left
|
|
575
|
+
// only dead handles behind fails here.
|
|
576
|
+
emit(h3.onHandlers, "tools/result", { name: "ask_user_question", agent: { id: "E" } }, {});
|
|
577
|
+
const pending = h3.timers.filter((t) => !t.ran && !t.fired);
|
|
578
|
+
ok("H24d at least one pending hold timer survived", pending.length >= 1, "pending=" + pending.length);
|
|
579
|
+
for (const t of pending) t.cb();
|
|
580
|
+
eq("H24e hold expiry after answer -> running", state3(), { state: "running", active: 1 });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
{
|
|
584
|
+
// H25–H28 — `defaultColor`: folded into the idle primary, echoed by the status
|
|
585
|
+
// endpoint, and judged by the CIE76 similarity warning (advisory: a value that
|
|
586
|
+
// collides with a state color is reported, never rejected).
|
|
587
|
+
const logs = [];
|
|
588
|
+
const h4 = makeCtx({ logger: logs });
|
|
589
|
+
plugin.apply(h4.ctx);
|
|
590
|
+
const statusRoute4 = h4.routes.find((r) => r.kind === "exact");
|
|
591
|
+
const aggregate4 = () => {
|
|
592
|
+
const res = fakeRes();
|
|
593
|
+
statusRoute4.handler(null, res);
|
|
594
|
+
return JSON.parse(res.body);
|
|
595
|
+
};
|
|
596
|
+
const opts4 = globalThis.__DSH_ICON_TEST__.settingsOpts;
|
|
597
|
+
|
|
598
|
+
// Absent defaultColor: unchanged behavior, no warning, no log.
|
|
599
|
+
const outOfTheBox = aggregate4();
|
|
600
|
+
ok("H25a absent defaultColor keeps the idle default and warns about nothing",
|
|
601
|
+
outOfTheBox.states.idle.colors[0] === "#1a1a1a" &&
|
|
602
|
+
Array.isArray(outOfTheBox.warnings) && outOfTheBox.warnings.length === 0,
|
|
603
|
+
JSON.stringify(outOfTheBox.warnings));
|
|
604
|
+
ok("H25b absent defaultColor logs nothing", logs.length === 0, JSON.stringify(logs));
|
|
605
|
+
|
|
606
|
+
// A distinct default color folds into the idle primary (colors[1] survives for
|
|
607
|
+
// a multi-color idle effect) and is echoed by the status payload.
|
|
608
|
+
opts4.setSource(() => ({
|
|
609
|
+
defaultColor: "#5B8DEF",
|
|
610
|
+
states: { idle: { effect: "breath", colors: ["#111111", "#DDDDDD"] } },
|
|
611
|
+
}));
|
|
612
|
+
opts4.onChange();
|
|
613
|
+
const distinct = aggregate4();
|
|
614
|
+
eq("H26a defaultColor overrides the idle primary", distinct.states.idle.colors[0], "#5b8def");
|
|
615
|
+
eq("H26b defaultColor keeps the idle state's secondary color for multi-color effects",
|
|
616
|
+
distinct.states.idle.colors[1], "#DDDDDD");
|
|
617
|
+
eq("H26c status echoes the resolved defaultColor", distinct.defaultColor, "#5b8def");
|
|
618
|
+
ok("H26d a distinct default color raises no similarity warning",
|
|
619
|
+
distinct.warnings.filter((w) => w.code === "color-too-close").length === 0,
|
|
620
|
+
JSON.stringify(distinct.warnings));
|
|
621
|
+
|
|
622
|
+
// Exactly the running color: strong (ΔE 0), and the asking blink shares it.
|
|
623
|
+
opts4.setSource(() => ({ defaultColor: "#FACC15", states: { idle: { effect: "static", colors: ["#111111"] } } }));
|
|
624
|
+
opts4.onChange();
|
|
625
|
+
const colliding = aggregate4();
|
|
626
|
+
const runningWarn = colliding.warnings.find((w) => w.state === "running" && w.code === "color-too-close");
|
|
627
|
+
ok("H27a a default color equal to the running color warns strongly",
|
|
628
|
+
!!runningWarn && runningWarn.level === "strong" && runningWarn.deltaE === 0 && runningWarn.base === "#facc15",
|
|
629
|
+
JSON.stringify(colliding.warnings));
|
|
630
|
+
ok("H27b the asking blink's shared color warns too",
|
|
631
|
+
colliding.warnings.some((w) => w.state === "asking" && w.code === "color-too-close"),
|
|
632
|
+
JSON.stringify(colliding.warnings));
|
|
633
|
+
ok("H27c the host logged the warning through ctx.logger",
|
|
634
|
+
logs.some((l) => l.includes("dsh-web-icon-indicator") && l.includes("#facc15")),
|
|
635
|
+
JSON.stringify(logs));
|
|
636
|
+
|
|
637
|
+
// The soft band (12 ≤ ΔE < 25) reports level "warn" instead of "strong".
|
|
638
|
+
opts4.setSource(() => ({ defaultColor: "#b8a000" }));
|
|
639
|
+
opts4.onChange();
|
|
640
|
+
const soft = aggregate4().warnings.find((w) => w.state === "running" && w.code === "color-too-close");
|
|
641
|
+
ok("H28a the soft band reports level warn",
|
|
642
|
+
!!soft && soft.level === "warn" && soft.deltaE > 12 && soft.deltaE < 25, JSON.stringify(soft));
|
|
643
|
+
|
|
644
|
+
// A malformed value is dropped (never painted) and reported.
|
|
645
|
+
opts4.setSource(() => ({ defaultColor: "#12" }));
|
|
646
|
+
opts4.onChange();
|
|
647
|
+
const malformed = aggregate4();
|
|
648
|
+
ok("H28b an invalid defaultColor is dropped, not painted (echoed as null)",
|
|
649
|
+
malformed.defaultColor === null && malformed.states.idle.colors[0] === "#1a1a1a",
|
|
650
|
+
JSON.stringify({ defaultColor: malformed.defaultColor, idle: malformed.states.idle }));
|
|
651
|
+
ok("H28c the invalid value is reported as a warning",
|
|
652
|
+
malformed.warnings.some((w) => w.code === "invalid-color" && w.value === "#12"),
|
|
653
|
+
JSON.stringify(malformed.warnings));
|
|
654
|
+
|
|
655
|
+
// A rainbow state sweeps every hue, so any chromatic default collides with it.
|
|
656
|
+
opts4.setSource(() => ({ defaultColor: "#5B8DEF", states: { done: { effect: "rainbow", colors: ["#22A06B"] } } }));
|
|
657
|
+
opts4.onChange();
|
|
658
|
+
const rainbow = aggregate4();
|
|
659
|
+
ok("H28d a rainbow state warns against a chromatic default",
|
|
660
|
+
rainbow.warnings.some((w) => w.code === "rainbow-overlap" && w.state === "done"),
|
|
661
|
+
JSON.stringify(rainbow.warnings));
|
|
662
|
+
ok("H28d2 a rainbow state does not also warn about a color it never paints",
|
|
663
|
+
!rainbow.warnings.some((w) => w.state === "done" && w.code === "color-too-close"),
|
|
664
|
+
JSON.stringify(rainbow.warnings));
|
|
665
|
+
|
|
666
|
+
// "" / whitespace in a hand-written composition entry means "unset", not a
|
|
667
|
+
// malformed color to warn about.
|
|
668
|
+
opts4.setSource(() => ({ defaultColor: " " }));
|
|
669
|
+
opts4.onChange();
|
|
670
|
+
const blank = aggregate4();
|
|
671
|
+
ok("H28e a blank defaultColor means unset (echoed as null)",
|
|
672
|
+
blank.defaultColor === null && blank.states.idle.colors[0] === "#1a1a1a" && blank.warnings.length === 0,
|
|
673
|
+
JSON.stringify({ defaultColor: blank.defaultColor, warnings: blank.warnings }));
|
|
674
|
+
|
|
675
|
+
// An empty YAML value (`defaultColor:`) parses as null: unset, not malformed.
|
|
676
|
+
opts4.setSource(() => ({ defaultColor: null }));
|
|
677
|
+
opts4.onChange();
|
|
678
|
+
const nullDefault = aggregate4();
|
|
679
|
+
ok("H28e2 a null defaultColor means unset too",
|
|
680
|
+
nullDefault.defaultColor === null && nullDefault.warnings.length === 0,
|
|
681
|
+
JSON.stringify({ defaultColor: nullDefault.defaultColor, warnings: nullDefault.warnings }));
|
|
682
|
+
|
|
683
|
+
// A state whose colors are entirely invalid falls back to its built-in color
|
|
684
|
+
// (resolveConfig), and the warning must judge that color — this pins the
|
|
685
|
+
// card's matching fallback (F36).
|
|
686
|
+
opts4.setSource(() => ({ defaultColor: "#FACC15", states: { running: { colors: ["red"] } } }));
|
|
687
|
+
opts4.onChange();
|
|
688
|
+
const fallback = aggregate4();
|
|
689
|
+
ok("H28f an invalid state color falls back to the built-in color before comparing",
|
|
690
|
+
fallback.states.running.colors[0] === "#FACC15" &&
|
|
691
|
+
fallback.warnings.some((w) => w.state === "running" && w.color === "#facc15" && w.deltaE === 0),
|
|
692
|
+
JSON.stringify({ running: fallback.states.running, warnings: fallback.warnings }));
|
|
693
|
+
|
|
694
|
+
// A 4/5-digit hex is not a colour either: dropped by the host exactly like the
|
|
695
|
+
// card drops it, never parsed into a bogus RGB triple.
|
|
696
|
+
opts4.setSource(() => ({ states: { running: { colors: ["#1234"] } } }));
|
|
697
|
+
opts4.onChange();
|
|
698
|
+
const baddigit = aggregate4();
|
|
699
|
+
ok("H28g a 4-digit hex state color is dropped, not parsed",
|
|
700
|
+
baddigit.states.running.colors[0] === "#FACC15", JSON.stringify(baddigit.states.running));
|
|
701
|
+
|
|
702
|
+
// A PARTIALLY invalid list keeps its valid entries (the card has to agree —
|
|
703
|
+
// F49 pins the card side).
|
|
704
|
+
opts4.setSource(() => ({ defaultColor: "#FF0000", states: { running: { colors: ["#FF0000", "red"] } } }));
|
|
705
|
+
opts4.onChange();
|
|
706
|
+
const partial = aggregate4();
|
|
707
|
+
ok("H28h a partially invalid colors list keeps the valid entries",
|
|
708
|
+
partial.states.running.colors.join(",") === "#FF0000" &&
|
|
709
|
+
partial.warnings.some((w) => w.state === "running" && w.color === "#ff0000" && w.deltaE === 0),
|
|
710
|
+
JSON.stringify({ running: partial.states.running, warnings: partial.warnings }));
|
|
711
|
+
|
|
712
|
+
// The browser paints breath as a CONTINUOUS mix: a blue -> green gradient runs
|
|
713
|
+
// straight through the configured default's own colour, which the old 5-point
|
|
714
|
+
// sampling missed entirely (reported ΔE 37 instead of 0).
|
|
715
|
+
opts4.setSource(() => ({
|
|
716
|
+
defaultColor: "#005aa5",
|
|
717
|
+
states: { running: { effect: "breath", colors: ["#0000ff", "#00ff00"] } },
|
|
718
|
+
}));
|
|
719
|
+
opts4.onChange();
|
|
720
|
+
const breathGap = aggregate4();
|
|
721
|
+
const breathWarn = breathGap.warnings.find((w) => w.state === "running" && w.code === "color-too-close");
|
|
722
|
+
ok("H28i a breath gradient is sampled densely enough to catch the collision",
|
|
723
|
+
!!breathWarn && breathWarn.deltaE < 8, JSON.stringify(breathGap.warnings));
|
|
724
|
+
|
|
725
|
+
// idle itself on `rainbow` (composition entry only): it sweeps every hue, so
|
|
726
|
+
// the default colour cannot stay distinguishable — one advisory, no pairwise pass.
|
|
727
|
+
opts4.setSource(() => ({ defaultColor: "#ff0000", states: { idle: { effect: "rainbow", colors: ["#1a1a1a"] } } }));
|
|
728
|
+
opts4.onChange();
|
|
729
|
+
const idleRainbow = aggregate4();
|
|
730
|
+
ok("H28j idle running rainbow is reported as a hue sweep",
|
|
731
|
+
idleRainbow.warnings.some((w) => w.code === "rainbow-overlap" && w.state === "idle") &&
|
|
732
|
+
!idleRainbow.warnings.some((w) => w.code === "color-too-close"),
|
|
733
|
+
JSON.stringify(idleRainbow.warnings));
|
|
734
|
+
opts4.setSource(() => ({ defaultColor: "#1a1a1a", states: { idle: { effect: "rainbow", colors: ["#1a1a1a"] } } }));
|
|
735
|
+
opts4.onChange();
|
|
736
|
+
ok("H28j2 a neutral default stays quiet even when idle is rainbow",
|
|
737
|
+
aggregate4().warnings.length === 0, JSON.stringify(aggregate4().warnings));
|
|
738
|
+
|
|
739
|
+
// A hand-written states.idle entry is honoured on its own (no defaultColor).
|
|
740
|
+
opts4.setSource(() => ({ states: { idle: { effect: "blink", colors: ["#111111", "#DDDDDD"], speed: 900 } } }));
|
|
741
|
+
opts4.onChange();
|
|
742
|
+
const idleOnly = aggregate4();
|
|
743
|
+
ok("H28k a hand-written states.idle entry survives without defaultColor",
|
|
744
|
+
idleOnly.states.idle.effect === "blink" &&
|
|
745
|
+
idleOnly.states.idle.colors.join(",") === "#111111,#DDDDDD" &&
|
|
746
|
+
idleOnly.defaultColor === null,
|
|
747
|
+
JSON.stringify({ idle: idleOnly.states.idle, defaultColor: idleOnly.defaultColor }));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
{
|
|
751
|
+
// H29 — the host log covers the other two warning codes as well (H27c only
|
|
752
|
+
// asserted the color-too-close line).
|
|
753
|
+
const logs2 = [];
|
|
754
|
+
const h5 = makeCtx({ logger: logs2 });
|
|
755
|
+
plugin.apply(h5.ctx);
|
|
756
|
+
const statusRoute5 = h5.routes.find((r) => r.kind === "exact");
|
|
757
|
+
const aggregate5 = () => {
|
|
758
|
+
const res = fakeRes();
|
|
759
|
+
statusRoute5.handler(null, res);
|
|
760
|
+
return JSON.parse(res.body);
|
|
761
|
+
};
|
|
762
|
+
const opts5 = globalThis.__DSH_ICON_TEST__.settingsOpts;
|
|
763
|
+
|
|
764
|
+
opts5.setSource(() => ({ defaultColor: "#zz" }));
|
|
765
|
+
opts5.onChange();
|
|
766
|
+
aggregate5();
|
|
767
|
+
ok("H29 an invalid defaultColor is logged with its value",
|
|
768
|
+
logs2.some((l) => l.includes("not a 3- or 6-digit hex color")), JSON.stringify(logs2));
|
|
769
|
+
|
|
770
|
+
opts5.setSource(() => ({ defaultColor: "#ff0000", states: { done: { effect: "rainbow", colors: ["#22A06B"] } } }));
|
|
771
|
+
opts5.onChange();
|
|
772
|
+
aggregate5();
|
|
773
|
+
ok("H29b a rainbow state is logged as an all-hue sweep",
|
|
774
|
+
logs2.some((l) => l.includes("rainbow effect sweeps every hue")), JSON.stringify(logs2));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ============================================================================
|
|
778
|
+
// PART 2 — browser injected script (real template, VM + DOM/fetch/rAF stubs)
|
|
779
|
+
// ============================================================================
|
|
780
|
+
function makeFakeLink(initialHref) {
|
|
781
|
+
const attrs = { rel: "icon", type: "", href: initialHref || "" };
|
|
782
|
+
return {
|
|
783
|
+
isConnected: true,
|
|
784
|
+
__attrs: attrs,
|
|
785
|
+
setAttribute(k, v) {
|
|
786
|
+
attrs[k] = String(v);
|
|
787
|
+
},
|
|
788
|
+
getAttribute(k) {
|
|
789
|
+
return k in attrs ? attrs[k] : null;
|
|
790
|
+
},
|
|
791
|
+
parentNode: {
|
|
792
|
+
replaceChild(fresh) {
|
|
793
|
+
// adopt the fresh node's attrs into this link
|
|
794
|
+
for (const k of Object.keys(fresh.__attrs)) attrs[k] = fresh.__attrs[k];
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
class BrowserDriver {
|
|
801
|
+
constructor({ initialHref = "http://orig.example/favicon.ico", states = DEFAULT_STATES, withFileReader = false } = {}) {
|
|
802
|
+
this.link = makeFakeLink(initialHref);
|
|
803
|
+
this.rafQueue = [];
|
|
804
|
+
this.pollFn = null;
|
|
805
|
+
this.queue = [];
|
|
806
|
+
this.aborts = []; // AbortController stubs handed to fetch, in request order
|
|
807
|
+
this.pendingTimeouts = []; // armed fetch-deadline callbacks (never auto-fired)
|
|
808
|
+
const ctrl = this;
|
|
809
|
+
const context = vm.createContext({
|
|
810
|
+
window: {
|
|
811
|
+
__DSH_WEB_ICON_INDICATOR__: false,
|
|
812
|
+
addEventListener(ev, fn) {
|
|
813
|
+
if (ev === "visibilitychange") ctrl.visFn = fn; // capture for the B18 test
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
document: {
|
|
817
|
+
visibilityState: "visible",
|
|
818
|
+
querySelector: (sel) => (sel.includes("icon") ? ctrl.link : null),
|
|
819
|
+
createElement: () => makeFakeLink(""),
|
|
820
|
+
head: { appendChild() {} },
|
|
821
|
+
},
|
|
822
|
+
location: { origin: "http://localhost:3080" },
|
|
823
|
+
setInterval: (fn) => {
|
|
824
|
+
ctrl.pollFn = fn;
|
|
825
|
+
return 1;
|
|
826
|
+
},
|
|
827
|
+
clearInterval() {},
|
|
828
|
+
requestAnimationFrame: (cb) => {
|
|
829
|
+
ctrl.rafQueue.push(cb);
|
|
830
|
+
return ctrl.rafQueue.length;
|
|
831
|
+
},
|
|
832
|
+
cancelAnimationFrame: () => {
|
|
833
|
+
ctrl.rafQueue.length = 0; // a new loop invalidates any stale steps
|
|
834
|
+
},
|
|
835
|
+
// Minimal AbortController so the script's deadline-bounded fetch path runs
|
|
836
|
+
// inside the VM; tests can drive abort() through d.aborts[i].
|
|
837
|
+
AbortController: class {
|
|
838
|
+
constructor() {
|
|
839
|
+
this.signal = { aborted: false };
|
|
840
|
+
ctrl.aborts.push(this);
|
|
841
|
+
}
|
|
842
|
+
abort() {
|
|
843
|
+
this.signal.aborted = true;
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
// Timer stubs for the script's fetch deadline: the tests never wait 8 s,
|
|
847
|
+
// they drive the abort through d.aborts[i].abort() instead.
|
|
848
|
+
setTimeout: (fn) => { ctrl.pendingTimeouts.push(fn); return ctrl.pendingTimeouts.length; },
|
|
849
|
+
clearTimeout: (id) => { if (id) ctrl.pendingTimeouts[id - 1] = null; },
|
|
850
|
+
fetch: (url, init) =>
|
|
851
|
+
new Promise((resolve, reject) => {
|
|
852
|
+
ctrl.queue.push({ url: String(url), resolve, reject, signal: init && init.signal });
|
|
853
|
+
}),
|
|
854
|
+
// Optional Blob/FileReader stubs: let the original-icon capture path of
|
|
855
|
+
// the offline-safe restore run inside the VM (fetch -> blob -> data URI).
|
|
856
|
+
...(withFileReader
|
|
857
|
+
? {
|
|
858
|
+
Blob: class BlobStub {},
|
|
859
|
+
FileReader: class FileReaderStub {
|
|
860
|
+
readAsDataURL() {
|
|
861
|
+
this.result = "data:image/x-icon;base64,AAEC";
|
|
862
|
+
if (this.onload) this.onload();
|
|
863
|
+
}
|
|
864
|
+
},
|
|
865
|
+
}
|
|
866
|
+
: {}),
|
|
867
|
+
});
|
|
868
|
+
this.context = context;
|
|
869
|
+
vm.runInContext(buildScript(states), context);
|
|
870
|
+
}
|
|
871
|
+
get href() {
|
|
872
|
+
// Favicon frames are encodeURIComponent'd data URIs; decode so assertions
|
|
873
|
+
// can match literal SVG substrings (rx="11", >3<, #E5484D, ...).
|
|
874
|
+
return decodeURIComponent(this.link.__attrs.href || "");
|
|
875
|
+
}
|
|
876
|
+
svgRes(text) {
|
|
877
|
+
return { ok: true, status: 200, text: async () => text, json: async () => ({}) };
|
|
878
|
+
}
|
|
879
|
+
statusRes(j) {
|
|
880
|
+
return { ok: true, status: 200, text: async () => "", json: async () => j };
|
|
881
|
+
}
|
|
882
|
+
// answer every queued request; responder(url) -> response object | Error
|
|
883
|
+
async pump(responder) {
|
|
884
|
+
while (this.queue.length) {
|
|
885
|
+
const req = this.queue.shift();
|
|
886
|
+
const out = responder(req.url);
|
|
887
|
+
if (out instanceof Error) req.reject(out);
|
|
888
|
+
else req.resolve(out);
|
|
889
|
+
}
|
|
890
|
+
await tick();
|
|
891
|
+
}
|
|
892
|
+
// The script polls once at load; consume that initial request deterministically
|
|
893
|
+
// so the first test-driven poll() sees a clean single-apply state.
|
|
894
|
+
async ready(initialStatus) {
|
|
895
|
+
await this.pump((url) =>
|
|
896
|
+
url.includes("/base.svg") ? this.svgRes(BASE_SVG) : this.statusRes(initialStatus)
|
|
897
|
+
);
|
|
898
|
+
if (this.queue.length) await this.pump((url) => this.svgRes(BASE_SVG)); // deferred base fetch
|
|
899
|
+
await tick();
|
|
900
|
+
}
|
|
901
|
+
// drive one poll: status json -> optional base fetch -> allow microtasks
|
|
902
|
+
async poll(statusJson) {
|
|
903
|
+
this.pollFn();
|
|
904
|
+
await this.pump((url) =>
|
|
905
|
+
url.includes("/base.svg") ? this.svgRes(BASE_SVG) : this.statusRes(statusJson)
|
|
906
|
+
);
|
|
907
|
+
if (this.queue.length) await this.pump((url) => this.svgRes(BASE_SVG)); // deferred base fetch
|
|
908
|
+
await tick();
|
|
909
|
+
}
|
|
910
|
+
async pollFail() {
|
|
911
|
+
this.pollFn();
|
|
912
|
+
await this.pump(() => new Error("network down"));
|
|
913
|
+
await tick();
|
|
914
|
+
}
|
|
915
|
+
raf(ts) {
|
|
916
|
+
this.rafQueue.shift()(ts);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
console.log("\n=== Part 2: browser injected script ===");
|
|
921
|
+
{
|
|
922
|
+
// B1 initial whale frame (active 1)
|
|
923
|
+
const d = new BrowserDriver();
|
|
924
|
+
await d.ready({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
925
|
+
ok("B1 whale frame painted", d.href.includes("M48.8354") && d.href.includes("FACC15"),
|
|
926
|
+
d.href.slice(0, 120));
|
|
927
|
+
|
|
928
|
+
// B2-B4 count transitions on one driver
|
|
929
|
+
await d.poll({ state: "running", active: 3, states: DEFAULT_STATES });
|
|
930
|
+
ok("B2 active 3 -> number block",
|
|
931
|
+
d.href.includes('rx="11"') && d.href.includes(">3<") && !d.href.includes("M48.8354"),
|
|
932
|
+
d.href.slice(0, 160));
|
|
933
|
+
await d.poll({ state: "running", active: 2, states: DEFAULT_STATES });
|
|
934
|
+
ok("B3 active 2 -> number 2", d.href.includes(">2<") && d.href.includes('rx="11"'));
|
|
935
|
+
await d.poll({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
936
|
+
ok("B4 active 1 -> whale back", d.href.includes("M48.8354"));
|
|
937
|
+
|
|
938
|
+
// B5 same-key repoll stable (no loop restart churn)
|
|
939
|
+
const frameBefore = d.href;
|
|
940
|
+
await d.poll({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
941
|
+
ok("B5 same key repoll -> identical frame", d.href === frameBefore);
|
|
942
|
+
|
|
943
|
+
// B6 legacy host (no active) keeps count; non-numeric active ignored
|
|
944
|
+
await d.poll({ state: "running", states: DEFAULT_STATES }); // no active field
|
|
945
|
+
ok("B6 legacy no active -> whale kept", d.href.includes("M48.8354"));
|
|
946
|
+
await d.poll({ state: "running", active: "2", states: DEFAULT_STATES }); // non-number ignored
|
|
947
|
+
ok("B6b string active ignored -> still whale", d.href.includes("M48.8354"));
|
|
948
|
+
await d.poll({ state: "running", active: 4, states: DEFAULT_STATES });
|
|
949
|
+
ok("B6c numeric active 4 -> number 4", d.href.includes(">4<"));
|
|
950
|
+
|
|
951
|
+
// B7 blink animates the count block fill
|
|
952
|
+
const b = new BrowserDriver();
|
|
953
|
+
await b.ready({ state: "asking", active: 2, states: DEFAULT_STATES });
|
|
954
|
+
ok("B7 blink initial frame is colors[0]",
|
|
955
|
+
b.href.includes("E5484D") && b.href.includes(">2<"), b.href.slice(0, 140));
|
|
956
|
+
b.raf(400); // t0 = 400 -> t = 0 -> c0
|
|
957
|
+
ok("B7b step t=0 -> E5484D", b.href.includes("E5484D"));
|
|
958
|
+
b.raf(800); // t = 400 -> toggle -> c1
|
|
959
|
+
ok("B7c step t=400 -> FACC15", b.href.includes("FACC15") && b.href.includes(">2<"));
|
|
960
|
+
|
|
961
|
+
// B8 rainbow cycles the count block fill
|
|
962
|
+
const rainbowStates = { ...DEFAULT_STATES, running: { effect: "rainbow", colors: ["#FACC15"], speed: 1200 } };
|
|
963
|
+
const r = new BrowserDriver({ states: rainbowStates });
|
|
964
|
+
await r.ready({ state: "running", active: 2, states: rainbowStates });
|
|
965
|
+
const r0 = r.href;
|
|
966
|
+
r.raf(800); // t0=800, t=0 -> same hue
|
|
967
|
+
r.raf(1600); // t=800 -> hue rotated
|
|
968
|
+
const r2 = r.href;
|
|
969
|
+
ok("B8 rainbow fills differ, both number blocks",
|
|
970
|
+
r0 !== r2 && r0.includes(">2<") && r2.includes(">2<") && r0.includes('rx="11"'));
|
|
971
|
+
|
|
972
|
+
// B9 syncCfg: settings change repaints with the new color
|
|
973
|
+
const s = new BrowserDriver();
|
|
974
|
+
await s.ready({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
975
|
+
ok("B9a baseline yellow whale", s.href.includes("FACC15"));
|
|
976
|
+
await s.poll({
|
|
977
|
+
state: "running",
|
|
978
|
+
active: 1,
|
|
979
|
+
states: { ...DEFAULT_STATES, running: { effect: "static", colors: ["#123456"] } },
|
|
980
|
+
});
|
|
981
|
+
ok("B9b new state color applied", s.href.includes("123456") && !s.href.includes("FACC15"));
|
|
982
|
+
|
|
983
|
+
// B10 outage with a server-URL original and NO cached copy (no FileReader
|
|
984
|
+
// in this driver): restore() must keep the last painted frame — writing the
|
|
985
|
+
// original URL would blank the tab, exactly the "backend stopped → icon
|
|
986
|
+
// lost" bug this guards against.
|
|
987
|
+
const f = new BrowserDriver({ initialHref: "http://orig.example/favicon.ico" });
|
|
988
|
+
await f.ready({ state: "running", active: 1, states: DEFAULT_STATES }); // paint something else
|
|
989
|
+
const lastFrame = f.href;
|
|
990
|
+
ok("B10a icon painted before the failure",
|
|
991
|
+
lastFrame.includes("data:image/svg+xml") && lastFrame.includes("FACC15"), lastFrame.slice(0, 120));
|
|
992
|
+
await f.pollFail();
|
|
993
|
+
ok("B10b outage keeps the last painted icon", f.href === lastFrame, f.href);
|
|
994
|
+
ok("B10c dead server URL never written on failure", !f.href.includes("orig.example"), f.href);
|
|
995
|
+
await f.pollFail();
|
|
996
|
+
ok("B10d repeated failures stay stable (restore once per outage)", f.href === lastFrame, f.href);
|
|
997
|
+
|
|
998
|
+
// B12 the original icon is captured as a data-URI copy at startup, so an
|
|
999
|
+
// outage restores THAT (renderable without the host) instead of a dead URL.
|
|
1000
|
+
const c = new BrowserDriver({ initialHref: "/favicon.svg", withFileReader: true });
|
|
1001
|
+
// Startup order: captureOriginal's fetch + the initial poll, then the
|
|
1002
|
+
// deferred base.svg fetch once apply() runs.
|
|
1003
|
+
const origRes = { ok: true, status: 200, blob: async () => ({}) };
|
|
1004
|
+
await c.pump((url) =>
|
|
1005
|
+
url.includes("/base.svg") ? c.svgRes(BASE_SVG)
|
|
1006
|
+
: url.includes("/favicon.svg") ? origRes
|
|
1007
|
+
: c.statusRes({ state: "running", active: 1, states: DEFAULT_STATES })
|
|
1008
|
+
);
|
|
1009
|
+
await c.pump((url) => (url.includes("/base.svg") ? c.svgRes(BASE_SVG) : new Error("unexpected " + url)));
|
|
1010
|
+
await tick();
|
|
1011
|
+
ok("B12a plugin frame painted", c.href.includes("data:image/svg+xml") && c.href.includes("FACC15"),
|
|
1012
|
+
c.href.slice(0, 120));
|
|
1013
|
+
await c.pollFail();
|
|
1014
|
+
ok("B12b outage restores the cached data-URI copy", c.href === "data:image/x-icon;base64,AAEC", c.href);
|
|
1015
|
+
await c.pollFail();
|
|
1016
|
+
ok("B12c repeated failures keep the restored icon stable", c.href === "data:image/x-icon;base64,AAEC", c.href);
|
|
1017
|
+
|
|
1018
|
+
// B11 old-host payload (no states) leaves CFG untouched
|
|
1019
|
+
const g = new BrowserDriver();
|
|
1020
|
+
await g.ready({ state: "running", active: 2, states: DEFAULT_STATES });
|
|
1021
|
+
ok("B11a number before legacy payload", g.href.includes(">2<"));
|
|
1022
|
+
await g.poll({ state: "running", active: 2 }); // no states -> older host
|
|
1023
|
+
ok("B11b old-host poll keeps frames", g.href.includes(">2<"));
|
|
1024
|
+
|
|
1025
|
+
// B13 geometric effects wrap the whale in a <g transform> (previously
|
|
1026
|
+
// untested: heartbeat and bounce were only ever exercised in the demo page).
|
|
1027
|
+
const hbStates = { ...DEFAULT_STATES, running: { effect: "heartbeat", colors: ["#FACC15"], speed: 1200 } };
|
|
1028
|
+
const hb = new BrowserDriver({ states: hbStates });
|
|
1029
|
+
await hb.ready({ state: "running", active: 1, states: hbStates });
|
|
1030
|
+
hb.raf(100); // t=0 -> outside the lub-dub window, scale 1
|
|
1031
|
+
ok("B13a heartbeat wraps the whale in a scale group",
|
|
1032
|
+
hb.href.includes('<g transform="translate(27.889625 24.95264) scale(1) translate(-27.889625 -24.95264)">') &&
|
|
1033
|
+
hb.href.includes("</g></svg>") && hb.href.includes("FACC15"),
|
|
1034
|
+
hb.href.slice(0, 200));
|
|
1035
|
+
hb.raf(160); // t=60ms -> 0.05 of the cycle -> first beat, scale > 1
|
|
1036
|
+
ok("B13b heartbeat beats inside the first window",
|
|
1037
|
+
/scale\(1\.\d+\)/.test(hb.href) && parseFloat(/scale\((1\.\d+)\)/.exec(hb.href)[1]) > 1.05,
|
|
1038
|
+
(hb.href.match(/scale\([^)]*\)/) || ["none"])[0]);
|
|
1039
|
+
|
|
1040
|
+
const boStates = { ...DEFAULT_STATES, running: { effect: "bounce", colors: ["#22A06B"], speed: 1200 } };
|
|
1041
|
+
const bo = new BrowserDriver({ states: boStates });
|
|
1042
|
+
await bo.ready({ state: "running", active: 1, states: boStates });
|
|
1043
|
+
bo.raf(100);
|
|
1044
|
+
ok("B13c bounce wraps the whale in a translate group",
|
|
1045
|
+
/<g transform="translate\(0 -?0\.\d+\)">/.test(bo.href) && bo.href.includes("22A06B"),
|
|
1046
|
+
(bo.href.match(/<g transform="[^"]*"/) || ["none"])[0]);
|
|
1047
|
+
bo.raf(312); // quarter of a 1.6-cycle -> maximum lift
|
|
1048
|
+
ok("B13d bounce lifts the whale", /translate\(0 -[1-9]/.test(bo.href), bo.href.slice(0, 200));
|
|
1049
|
+
|
|
1050
|
+
// B14 breath interpolates between colors[0] and colors[1]. Sample at
|
|
1051
|
+
// t = speed/4 and t = 3·speed/4 (the two extremes) — t = speed is the
|
|
1052
|
+
// midpoint again, because sin(2π) is -2.4e-16 rather than exactly 0.
|
|
1053
|
+
const brStates = { ...DEFAULT_STATES, running: { effect: "breath", colors: ["#000000", "#FFFFFF"], speed: 1200 } };
|
|
1054
|
+
const br = new BrowserDriver({ states: brStates });
|
|
1055
|
+
await br.ready({ state: "running", active: 1, states: brStates });
|
|
1056
|
+
const fillOf = (d) => (/fill: (#[0-9a-f]{6})/.exec(d.href) || [])[1] || "";
|
|
1057
|
+
br.raf(100); // t=0 -> midpoint
|
|
1058
|
+
const midFill = fillOf(br);
|
|
1059
|
+
br.raf(400); // t=300 -> quarter cycle -> colors[1]
|
|
1060
|
+
const firstExtreme = fillOf(br);
|
|
1061
|
+
br.raf(1000); // t=900 -> three-quarter cycle -> colors[0]
|
|
1062
|
+
const secondExtreme = fillOf(br);
|
|
1063
|
+
const lum = (hex) => parseInt(hex.slice(1, 3), 16) + parseInt(hex.slice(3, 5), 16) + parseInt(hex.slice(5, 7), 16);
|
|
1064
|
+
ok("B14a breath midpoint between the two colors", midFill === "#808080", midFill);
|
|
1065
|
+
ok("B14b breath reaches both configured colors",
|
|
1066
|
+
firstExtreme === "#ffffff" && secondExtreme === "#000000" &&
|
|
1067
|
+
lum(firstExtreme) > lum(midFill) && lum(secondExtreme) < lum(midFill),
|
|
1068
|
+
"q1=" + firstExtreme + " mid=" + midFill + " q3=" + secondExtreme);
|
|
1069
|
+
|
|
1070
|
+
// B15 hidden-tab fallback: an unchanged static state repaints (self-heal)
|
|
1071
|
+
// instead of leaving a stale frame; an unchanged animated state gets a
|
|
1072
|
+
// wall-clock frame rather than freezing.
|
|
1073
|
+
const sh = new BrowserDriver();
|
|
1074
|
+
await sh.ready({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
1075
|
+
const shFrame = sh.href;
|
|
1076
|
+
sh.link.__attrs.href = "data:image/svg+xml,stale";
|
|
1077
|
+
await sh.poll({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
1078
|
+
ok("B15a unchanged static poll self-heals the frame", sh.href === shFrame && !sh.href.includes("stale"));
|
|
1079
|
+
|
|
1080
|
+
// B16 the count block does not depend on base.svg: with the template
|
|
1081
|
+
// permanently unavailable the count must still render (only the whale waits).
|
|
1082
|
+
const nb = new BrowserDriver();
|
|
1083
|
+
await nb.pump((url) => (url.includes("/base.svg") ? new Error("base down") : nb.statusRes({ state: "running", active: 3, states: DEFAULT_STATES })));
|
|
1084
|
+
await tick();
|
|
1085
|
+
ok("B16a no base.svg -> no whale frame", !nb.href.includes("M48.8354"), nb.href.slice(0, 120));
|
|
1086
|
+
await nb.poll({ state: "running", active: 3, states: DEFAULT_STATES });
|
|
1087
|
+
await nb.pump((url) => (url.includes("/base.svg") ? new Error("base down") : nb.statusRes({ state: "running", active: 3, states: DEFAULT_STATES })));
|
|
1088
|
+
ok("B16b count block renders without base.svg", nb.href.includes(">3<") && nb.href.includes('rx="11"'), nb.href.slice(0, 160));
|
|
1089
|
+
|
|
1090
|
+
// B17 every request is deadline-bounded: fetch receives an AbortSignal, and a
|
|
1091
|
+
// request that hangs is aborted instead of holding the poll chain forever.
|
|
1092
|
+
const t = new BrowserDriver();
|
|
1093
|
+
await t.pump((url) => (url.includes("/base.svg") ? t.svgRes(BASE_SVG) : t.statusRes({ state: "running", active: 1, states: DEFAULT_STATES })));
|
|
1094
|
+
ok("B17a fetch requests carry an abort signal",
|
|
1095
|
+
t.aborts.length > 0 && t.aborts.every((a) => a.signal && a.signal.aborted === false),
|
|
1096
|
+
"controllers=" + t.aborts.length);
|
|
1097
|
+
ok("B17b a deadline timer was armed per request", t.pendingTimeouts.length > 0,
|
|
1098
|
+
"timers=" + t.pendingTimeouts.length);
|
|
1099
|
+
// Simulate the deadline expiring on the in-flight poll.
|
|
1100
|
+
t.pollFn();
|
|
1101
|
+
const hanging = t.queue[t.queue.length - 1];
|
|
1102
|
+
t.aborts[t.aborts.length - 1].abort();
|
|
1103
|
+
ok("B17c aborting marks the request signal", hanging.signal.aborted === true);
|
|
1104
|
+
hanging.reject(new Error("AbortError"));
|
|
1105
|
+
await tick();
|
|
1106
|
+
// The poll chain survived the abort: the next tick still works.
|
|
1107
|
+
await t.poll({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
1108
|
+
ok("B17d poll recovers after an aborted request", t.href.includes("FACC15"));
|
|
1109
|
+
|
|
1110
|
+
// B18 returning to a hidden tab repaints immediately: the visibilitychange
|
|
1111
|
+
// listener fires a fresh poll instead of waiting for the next — possibly
|
|
1112
|
+
// throttled — interval tick, so a revert that happened while the tab was
|
|
1113
|
+
// away (e.g. done -> idle) shows the moment the tab becomes visible again.
|
|
1114
|
+
const v = new BrowserDriver();
|
|
1115
|
+
await v.ready({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
1116
|
+
v.context.document.visibilityState = "hidden";
|
|
1117
|
+
await v.poll({ state: "done", active: 1, states: DEFAULT_STATES }); // what the tab saw before going away
|
|
1118
|
+
ok("B18a done frame painted while 'hidden'", v.href.includes("22A06B"), v.href.slice(0, 120));
|
|
1119
|
+
// The host reverts to idle while the tab is away; the tab becomes visible.
|
|
1120
|
+
v.context.document.visibilityState = "visible";
|
|
1121
|
+
ok("B18b visibilitychange listener captured", typeof v.visFn === "function");
|
|
1122
|
+
v.visFn(); // fires the immediate poll
|
|
1123
|
+
await v.pump((url) =>
|
|
1124
|
+
url.includes("/base.svg") ? v.svgRes(BASE_SVG) : v.statusRes({ state: "idle", active: 0, states: DEFAULT_STATES })
|
|
1125
|
+
);
|
|
1126
|
+
if (v.queue.length) await v.pump((url) => v.svgRes(BASE_SVG)); // deferred base fetch
|
|
1127
|
+
await tick();
|
|
1128
|
+
ok("B18c visible-return poll repaints the reverted idle frame",
|
|
1129
|
+
v.href.includes("1a1a1a") && !v.href.includes("22A06B"), v.href.slice(0, 160));
|
|
1130
|
+
// A hidden->visible transition must NOT clobber an animated frame either:
|
|
1131
|
+
// poll a different state while visible and confirm the listener path stays safe.
|
|
1132
|
+
await v.poll({ state: "running", active: 1, states: DEFAULT_STATES });
|
|
1133
|
+
ok("B18d subsequent polls unaffected after visible-return poll", v.href.includes("FACC15"));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ============================================================================
|
|
1137
|
+
// PART 3 — bigNumUri unit checks (extracted from the template)
|
|
1138
|
+
// ============================================================================
|
|
1139
|
+
console.log("\n=== Part 3: bigNumUri rendering ===");
|
|
1140
|
+
{
|
|
1141
|
+
const bigNumSrc = extractBlock(TEMPLATE, "function bigNumUri(fill) {");
|
|
1142
|
+
// bigNumUri memoizes through cachedUri, so the extracted slice needs that
|
|
1143
|
+
// helper too (plus the map it closes over).
|
|
1144
|
+
const cachedUriSrc = extractBlock(TEMPLATE, "function cachedUri(kind, fill, build) {");
|
|
1145
|
+
function hexToRgbStub(h) {
|
|
1146
|
+
h = String(h).replace("#", "");
|
|
1147
|
+
if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
|
|
1148
|
+
const n = parseInt(h, 16);
|
|
1149
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
1150
|
+
}
|
|
1151
|
+
const makeBigNum = (ACTIVE) =>
|
|
1152
|
+
new Function(
|
|
1153
|
+
"ACTIVE", "BIG_NUM_RX", "hexToRgb", "encodeURIComponent", "URI_CACHE",
|
|
1154
|
+
cachedUriSrc + "\n" + bigNumSrc + "\nreturn bigNumUri;"
|
|
1155
|
+
)(ACTIVE, 11, hexToRgbStub, (s) => s, new Map());
|
|
1156
|
+
|
|
1157
|
+
// contrast: dark/red/green -> white text; bright -> dark text
|
|
1158
|
+
for (const [fill, wantFg] of [
|
|
1159
|
+
["#1a1a1a", "#FFFFFF"],
|
|
1160
|
+
["#E5484D", "#FFFFFF"],
|
|
1161
|
+
["#22A06B", "#FFFFFF"],
|
|
1162
|
+
["#FACC15", "#111111"],
|
|
1163
|
+
["#ffffff", "#111111"],
|
|
1164
|
+
]) {
|
|
1165
|
+
const out = makeBigNum(2)(fill);
|
|
1166
|
+
ok("C contrast " + fill + " -> " + wantFg,
|
|
1167
|
+
out.includes('fill="' + wantFg + '"') && out.includes('fill="' + fill + '"'));
|
|
1168
|
+
}
|
|
1169
|
+
ok("C2 one digit fontSize 26", makeBigNum(2)("#FACC15").includes('font-size="26"'));
|
|
1170
|
+
const two = makeBigNum(12)("#FACC15");
|
|
1171
|
+
ok("C3 two digits fontSize 20 + text", two.includes('font-size="20"') && two.includes(">12<"));
|
|
1172
|
+
const capped = makeBigNum(100)("#FACC15");
|
|
1173
|
+
ok("C4 cap 99+ fontSize 15.5", capped.includes(">99+<") && capped.includes('font-size="15.5"'));
|
|
1174
|
+
ok("C5 rounded rect rx=11 full frame", makeBigNum(2)("#FACC15").includes('width="50" height="50" rx="11"'));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// ============================================================================
|
|
1178
|
+
// PART 4 — injected script syntax
|
|
1179
|
+
// ============================================================================
|
|
1180
|
+
console.log("\n=== Part 4: injected script syntax ===");
|
|
1181
|
+
{
|
|
1182
|
+
const tmp = path.join(os.tmpdir(), "dsh-injected-check-" + process.pid + ".js");
|
|
1183
|
+
writeFileSync(tmp, builtScript);
|
|
1184
|
+
const out = spawnSync(process.execPath, ["--check", tmp], { encoding: "utf8" });
|
|
1185
|
+
ok("D1 injected script parses", out.status === 0, out.stderr);
|
|
1186
|
+
unlinkSync(tmp);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ============================================================================
|
|
1190
|
+
// PART 5 — demo/badge.html inline script syntax
|
|
1191
|
+
// ============================================================================
|
|
1192
|
+
console.log("\n=== Part 5: demo/badge.html inline script syntax ===");
|
|
1193
|
+
{
|
|
1194
|
+
// demo/ is a repo-only playground (not in the npm `files` allowlist), so an
|
|
1195
|
+
// installed tarball must still be able to run `npm test`.
|
|
1196
|
+
let html = null;
|
|
1197
|
+
try {
|
|
1198
|
+
html = readFileSync(new URL("../demo/badge.html", import.meta.url), "utf8");
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
html = null;
|
|
1201
|
+
}
|
|
1202
|
+
if (html === null) {
|
|
1203
|
+
console.log("SKIP E1/E2 demo/badge.html not present (published tarball)");
|
|
1204
|
+
} else {
|
|
1205
|
+
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
|
1206
|
+
ok("E1 demo has an inline script", !!m);
|
|
1207
|
+
if (m) {
|
|
1208
|
+
const tmp = path.join(os.tmpdir(), "badge-inline-" + process.pid + ".js");
|
|
1209
|
+
writeFileSync(tmp, m[1]);
|
|
1210
|
+
const out = spawnSync(process.execPath, ["--check", tmp], { encoding: "utf8" });
|
|
1211
|
+
ok("E2 demo inline script parses + threshold aligned",
|
|
1212
|
+
out.status === 0 && m[1].includes('count() >= 2') && m[1].includes('n < 2'), out.stderr);
|
|
1213
|
+
unlinkSync(tmp);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ============================================================================
|
|
1219
|
+
// PART 6 — browser half (lib/client.js): loader contract, card render, writes
|
|
1220
|
+
// ============================================================================
|
|
1221
|
+
// The card bundle is a hand-written ModuleLoader factory with two shell-provided
|
|
1222
|
+
// requires (`react`, `@deepseek-ai/dsh-client-ui-primitives`). Both are stubbed
|
|
1223
|
+
// here — zero dependencies, no build step — so the REAL bundle runs and the
|
|
1224
|
+
// rendered tree plus the scope writes it performs can be asserted.
|
|
1225
|
+
console.log("\n=== Part 6: browser half (lib/client.js) ===");
|
|
1226
|
+
{
|
|
1227
|
+
const CLIENT_SRC = readFileSync(new URL("lib/client.js", REPO), "utf8");
|
|
1228
|
+
|
|
1229
|
+
/** Minimal React stub: createElement trees + the two hooks the card uses. */
|
|
1230
|
+
function makeReactStub() {
|
|
1231
|
+
let states = [];
|
|
1232
|
+
let cursor = 0;
|
|
1233
|
+
let rerender = () => {};
|
|
1234
|
+
const createElement = (type, props, ...children) => ({
|
|
1235
|
+
type,
|
|
1236
|
+
props: { ...(props || {}), children: children.length <= 1 ? children[0] : children },
|
|
1237
|
+
});
|
|
1238
|
+
return {
|
|
1239
|
+
createElement,
|
|
1240
|
+
useState(initial) {
|
|
1241
|
+
const i = cursor++;
|
|
1242
|
+
if (!(i in states)) states[i] = initial;
|
|
1243
|
+
return [states[i], (next) => {
|
|
1244
|
+
states[i] = typeof next === "function" ? next(states[i]) : next;
|
|
1245
|
+
rerender();
|
|
1246
|
+
}];
|
|
1247
|
+
},
|
|
1248
|
+
useSyncExternalStore(subscribe, getSnapshot) {
|
|
1249
|
+
return getSnapshot();
|
|
1250
|
+
},
|
|
1251
|
+
__reset() { states = []; cursor = 0; },
|
|
1252
|
+
__beginRender(fn) { cursor = 0; rerender = fn; },
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const primitivesStub = {
|
|
1257
|
+
Button: function Button() { return null; },
|
|
1258
|
+
Input: function Input() { return null; },
|
|
1259
|
+
IconChevronDownOutline14: function IconChevronDownOutline14() { return null; },
|
|
1260
|
+
DisclosureRow: function DisclosureRow() { return null; },
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
/** Walk a createElement tree, yielding every node (depth first). */
|
|
1264
|
+
function* walk(node) {
|
|
1265
|
+
if (node === null || node === undefined || node === false) return;
|
|
1266
|
+
if (Array.isArray(node)) {
|
|
1267
|
+
for (const child of node) yield* walk(child);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (typeof node !== "object" || !("type" in node)) return;
|
|
1271
|
+
yield node;
|
|
1272
|
+
yield* walk(node.props?.children);
|
|
1273
|
+
}
|
|
1274
|
+
const findAll = (tree, pred) => [...walk(tree)].filter(pred);
|
|
1275
|
+
const textOf = (tree) => [...walk(tree)].filter((n) => typeof n.props?.children === "string")
|
|
1276
|
+
.map((n) => n.props.children).join(" ");
|
|
1277
|
+
const findButton = (tree, label) =>
|
|
1278
|
+
findAll(tree, (n) => n.type === primitivesStub.Button && n.props.children === label)[0] || null;
|
|
1279
|
+
|
|
1280
|
+
// ---- loader + factory ----------------------------------------------------
|
|
1281
|
+
let loaded = null;
|
|
1282
|
+
const loaderWindow = {
|
|
1283
|
+
__ModuleLoader__: {
|
|
1284
|
+
load(def) { loaded = def; },
|
|
1285
|
+
},
|
|
1286
|
+
};
|
|
1287
|
+
const vmCtx = vm.createContext({ window: loaderWindow });
|
|
1288
|
+
vm.runInContext(CLIENT_SRC, vmCtx);
|
|
1289
|
+
ok("F1 bundle registers through window.__ModuleLoader__", !!loaded && loaded.id === "dsh-web-icon-indicator",
|
|
1290
|
+
"id=" + (loaded && loaded.id));
|
|
1291
|
+
ok("F2 bundle declares a factory", !!loaded && typeof loaded.factory === "function");
|
|
1292
|
+
|
|
1293
|
+
const react = makeReactStub();
|
|
1294
|
+
const requireStub = (name) => {
|
|
1295
|
+
if (name === "react") return react;
|
|
1296
|
+
if (name === "@deepseek-ai/dsh-client-ui-primitives") return primitivesStub;
|
|
1297
|
+
throw new Error("unexpected require: " + name);
|
|
1298
|
+
};
|
|
1299
|
+
const client = loaded.factory(requireStub);
|
|
1300
|
+
ok("F3 exports apply + inject", typeof client.apply === "function" && Array.isArray(client.inject));
|
|
1301
|
+
eq("F4 inject list", client.inject, ["slots", "settingsScope", "locale"]);
|
|
1302
|
+
|
|
1303
|
+
// ---- apply() contract ----------------------------------------------------
|
|
1304
|
+
const registered = [];
|
|
1305
|
+
const dictionaries = [];
|
|
1306
|
+
let boundSpec = null;
|
|
1307
|
+
const scope = {
|
|
1308
|
+
getSnapshot: () => snapshot,
|
|
1309
|
+
subscribe: () => () => {},
|
|
1310
|
+
set: async (field, value) => { writes.push({ op: "set", field, value }); },
|
|
1311
|
+
unset: async (field) => { writes.push({ op: "unset", field }); },
|
|
1312
|
+
mutate: async (ops) => { writes.push({ op: "mutate", ops }); },
|
|
1313
|
+
};
|
|
1314
|
+
const writes = [];
|
|
1315
|
+
const snapshot = {
|
|
1316
|
+
status: "ready",
|
|
1317
|
+
writable: true,
|
|
1318
|
+
value: {
|
|
1319
|
+
askingHoldMs: 3500,
|
|
1320
|
+
doneHoldMs: 5000,
|
|
1321
|
+
states: {
|
|
1322
|
+
idle: { effect: "static", colors: ["#1a1a1a"] },
|
|
1323
|
+
running: { effect: "static", colors: ["#FACC15"] },
|
|
1324
|
+
asking: { effect: "blink", colors: ["#E5484D", "#FACC15"], speed: 400 },
|
|
1325
|
+
done: { effect: "static", colors: ["#22A06B"] },
|
|
1326
|
+
},
|
|
1327
|
+
},
|
|
1328
|
+
base: {},
|
|
1329
|
+
user: {},
|
|
1330
|
+
revision: 1,
|
|
1331
|
+
mode: "host",
|
|
1332
|
+
};
|
|
1333
|
+
const clientCtx = {
|
|
1334
|
+
locale: {
|
|
1335
|
+
register(ns, dicts) { dictionaries.push({ ns, dicts }); return () => {}; },
|
|
1336
|
+
bind(ns) { return (key) => (dictionaries[0]?.dicts?.en?.[key] ?? key); },
|
|
1337
|
+
},
|
|
1338
|
+
settingsScope: {
|
|
1339
|
+
bind(spec) { boundSpec = spec; return scope; },
|
|
1340
|
+
},
|
|
1341
|
+
slots: {
|
|
1342
|
+
inject(name, cb) { cb(); },
|
|
1343
|
+
register(slotSpec, component) { registered.push({ slotSpec, component }); return () => {}; },
|
|
1344
|
+
},
|
|
1345
|
+
effect(fn) { return fn(); },
|
|
1346
|
+
};
|
|
1347
|
+
client.apply(clientCtx);
|
|
1348
|
+
|
|
1349
|
+
eq("F5 settingsScope bound to the join key", boundSpec && boundSpec.namespace, "web-icon-indicator");
|
|
1350
|
+
ok("F6 locale dictionaries registered (en + zh)",
|
|
1351
|
+
dictionaries.length === 1 && !!dictionaries[0].dicts.en && !!dictionaries[0].dicts.zh,
|
|
1352
|
+
"ns=" + (dictionaries[0] && dictionaries[0].ns));
|
|
1353
|
+
ok("F7 one card registered into settings.plugin.item",
|
|
1354
|
+
registered.length === 1 && registered[0].slotSpec.name === "settings.plugin.item",
|
|
1355
|
+
"count=" + registered.length);
|
|
1356
|
+
const slotSpec = registered[0] && registered[0].slotSpec;
|
|
1357
|
+
eq("F8 slot key is the settings namespace", slotSpec && slotSpec.key, "web-icon-indicator");
|
|
1358
|
+
eq("F9 slot locale namespace", slotSpec && slotSpec.locale, "dsh-web-icon-indicator");
|
|
1359
|
+
const injected = slotSpec && slotSpec.inject();
|
|
1360
|
+
ok("F10 slot inject provides scope + t",
|
|
1361
|
+
injected && injected.scope === scope && typeof injected.t === "function");
|
|
1362
|
+
|
|
1363
|
+
// ---- card render ---------------------------------------------------------
|
|
1364
|
+
// React is stubbed, so a re-render is driven explicitly: the card's own
|
|
1365
|
+
// setState calls `rerender()`, which re-invokes the component with the same
|
|
1366
|
+
// props against the same hook storage.
|
|
1367
|
+
const Card = registered[0].component;
|
|
1368
|
+
const renderCard = (props) => {
|
|
1369
|
+
let tree = null;
|
|
1370
|
+
const render = () => { react.__beginRender(render); tree = Card(props); };
|
|
1371
|
+
render();
|
|
1372
|
+
return () => tree;
|
|
1373
|
+
};
|
|
1374
|
+
const t = injected.t;
|
|
1375
|
+
const cardProps = { scope, t };
|
|
1376
|
+
const card = renderCard(cardProps);
|
|
1377
|
+
const collapsed = card();
|
|
1378
|
+
ok("F11 card renders its title", textOf(collapsed).includes("Favicon indicator"),
|
|
1379
|
+
textOf(collapsed).slice(0, 80));
|
|
1380
|
+
ok("F12 card is collapsed by default (no fields yet)",
|
|
1381
|
+
findAll(collapsed, (n) => n.props && n.props.id === "plugin-config-icon-asking-hold").length === 0);
|
|
1382
|
+
|
|
1383
|
+
// Expand it (the header button toggles local state and re-renders).
|
|
1384
|
+
const header = findAll(collapsed, (n) => n.type === "button")[0];
|
|
1385
|
+
ok("F13 header button toggles the card", !!header && typeof header.props.onClick === "function");
|
|
1386
|
+
header.props.onClick();
|
|
1387
|
+
const openTree = card();
|
|
1388
|
+
ok("F14 expanded card exposes both hold fields",
|
|
1389
|
+
findAll(openTree, (n) => n.props && n.props.id === "plugin-config-icon-asking-hold").length === 1 &&
|
|
1390
|
+
findAll(openTree, (n) => n.props && n.props.id === "plugin-config-icon-done-hold").length === 1);
|
|
1391
|
+
ok("F15 the animated / signal states get a detail row",
|
|
1392
|
+
["Running", "Asking", "Done"].every((name) =>
|
|
1393
|
+
findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === name).length === 1),
|
|
1394
|
+
textOf(openTree).slice(0, 120));
|
|
1395
|
+
// Idle has no detail row: its single color is the default-color field and it
|
|
1396
|
+
// takes no animation, so no idle effect/colors/cycle field may render.
|
|
1397
|
+
ok("F15b idle has no detail row and no idle field",
|
|
1398
|
+
findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Idle").length === 0 &&
|
|
1399
|
+
findAll(openTree, (n) => n.props && typeof n.props.id === "string" && n.props.id.indexOf("plugin-config-icon-idle-") === 0).length === 0,
|
|
1400
|
+
textOf(openTree).slice(0, 160));
|
|
1401
|
+
// The row header previews EVERY color of the state: asking blinks between two
|
|
1402
|
+
// colors, so a single dot hid one of them.
|
|
1403
|
+
const askingRowNode = findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Asking")[0];
|
|
1404
|
+
const askingIcon = askingRowNode.props.icon;
|
|
1405
|
+
const askingBands = askingIcon && askingIcon.props && askingIcon.props.children;
|
|
1406
|
+
ok("F15c the row icon is ONE chip split into explicit bands (asking: red | yellow)",
|
|
1407
|
+
!!askingIcon && askingIcon.props.style.position === "relative" &&
|
|
1408
|
+
Array.isArray(askingBands) && askingBands.length === 2 &&
|
|
1409
|
+
askingBands.map((b) => b.props.style.background).join(",") === "#E5484D,#FACC15" &&
|
|
1410
|
+
// 1px seam between the bands, and the last band overflows so the right edge
|
|
1411
|
+
// never shows a gap (the chip clips it).
|
|
1412
|
+
askingBands[0].props.style.width === "calc(50% - 1px)" &&
|
|
1413
|
+
askingBands[1].props.style.width === "calc(50% + 1px)",
|
|
1414
|
+
JSON.stringify(askingBands && askingBands.map((b) => b.props.style)));
|
|
1415
|
+
const runningIcon = findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Running")[0].props.icon;
|
|
1416
|
+
ok("F15c2 a single-color state is one full-width band",
|
|
1417
|
+
runningIcon.props.children.length === 1 &&
|
|
1418
|
+
runningIcon.props.children[0].props.style.background === "#FACC15" &&
|
|
1419
|
+
runningIcon.props.children[0].props.style.width === "calc(100% + 1px)",
|
|
1420
|
+
JSON.stringify(runningIcon.props.children[0].props.style));
|
|
1421
|
+
// The colors field edits chips: asking carries both colors plus add/remove,
|
|
1422
|
+
// while a static state (no second color in play) offers no add chip.
|
|
1423
|
+
const askingColorsField = findAll(openTree, (n) => n.props && n.props.id === "plugin-config-icon-asking-colors")[0];
|
|
1424
|
+
const runningColorsField = findAll(openTree, (n) => n.props && n.props.id === "plugin-config-icon-running-colors")[0];
|
|
1425
|
+
ok("F15d a full two-colour list exposes both colours and a remove (no add) affordance",
|
|
1426
|
+
!!askingColorsField && askingColorsField.props.colors.join(",") === "#E5484D,#FACC15" &&
|
|
1427
|
+
askingColorsField.props.canAdd === false && askingColorsField.props.canRemove === true &&
|
|
1428
|
+
!!runningColorsField && runningColorsField.props.canAdd === false,
|
|
1429
|
+
JSON.stringify({
|
|
1430
|
+
asking: askingColorsField && askingColorsField.props.colors,
|
|
1431
|
+
askingAdd: askingColorsField && askingColorsField.props.canAdd,
|
|
1432
|
+
runningAdd: runningColorsField && runningColorsField.props.canAdd,
|
|
1433
|
+
}));
|
|
1434
|
+
const askingRow = findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Asking")[0];
|
|
1435
|
+
// `textOf` only collects single-string children; the summary mixes strings
|
|
1436
|
+
// with chip elements, so walk the children recursively for its text.
|
|
1437
|
+
const deepText = (node) => {
|
|
1438
|
+
if (node === null || node === undefined || node === false) return "";
|
|
1439
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
1440
|
+
if (Array.isArray(node)) return node.map(deepText).join(" ");
|
|
1441
|
+
if (typeof node !== "object" || !("type" in node)) return "";
|
|
1442
|
+
return deepText(node.props && node.props.children);
|
|
1443
|
+
};
|
|
1444
|
+
const askingSummary = deepText(askingRow.props.collapsedContent);
|
|
1445
|
+
const summaryChips = findAll(askingRow.props.collapsedContent, (n) => n.props && n.props.style && n.props.style.background);
|
|
1446
|
+
ok("F16 the summary stays compact text (effect + cycle), no hex and no repeated chips",
|
|
1447
|
+
askingSummary === "Blink · 400ms" && summaryChips.length === 0,
|
|
1448
|
+
JSON.stringify({ text: askingSummary, chips: summaryChips.length }));
|
|
1449
|
+
|
|
1450
|
+
// ---- writes --------------------------------------------------------------
|
|
1451
|
+
const saveBtn = findButton(openTree, "Save");
|
|
1452
|
+
const discardBtn = findButton(openTree, "Discard");
|
|
1453
|
+
const resetBtn = findButton(openTree, "Reset to defaults");
|
|
1454
|
+
ok("F17 footer exposes Save / Discard / Reset", !!saveBtn && !!discardBtn && !!resetBtn);
|
|
1455
|
+
ok("F18 Save is disabled while the form is pristine", saveBtn.props.disabled === true);
|
|
1456
|
+
|
|
1457
|
+
// Reset clears the three namespace keys back to the composition layer. The
|
|
1458
|
+
// handler itself is synchronous; the UI settles on the next microtask.
|
|
1459
|
+
writes.length = 0;
|
|
1460
|
+
resetBtn.props.onClick();
|
|
1461
|
+
await tick();
|
|
1462
|
+
eq("F19 reset unsets every namespace key",
|
|
1463
|
+
writes.map((w) => w.op + ":" + w.field).sort(),
|
|
1464
|
+
["unset:askingHoldMs", "unset:defaultColor", "unset:doneHoldMs", "unset:states"]);
|
|
1465
|
+
ok("F19b form returns to pristine after reset", findButton(card(), "Save").props.disabled === true);
|
|
1466
|
+
|
|
1467
|
+
// ---- a staged edit saves as ONE atomic mutation --------------------------
|
|
1468
|
+
writes.length = 0;
|
|
1469
|
+
const byId = (tree, id) => findAll(tree, (n) => n.props && n.props.id === id)[0] || null;
|
|
1470
|
+
const holdField = byId(openTree, "plugin-config-icon-asking-hold");
|
|
1471
|
+
ok("F20 hold field bound to the resolved value", holdField && holdField.props.value === "3500",
|
|
1472
|
+
holdField && holdField.props.value);
|
|
1473
|
+
holdField.props.onChange("2500");
|
|
1474
|
+
const dirtyTree = card();
|
|
1475
|
+
const dirtySave = findButton(dirtyTree, "Save");
|
|
1476
|
+
ok("F21 editing a field marks the form dirty", dirtySave.props.disabled === false);
|
|
1477
|
+
ok("F22 unsaved badge appears", textOf(dirtyTree).includes("Unsaved"));
|
|
1478
|
+
dirtySave.props.onClick();
|
|
1479
|
+
await tick();
|
|
1480
|
+
eq("F23 save issues one atomic mutate with path ops",
|
|
1481
|
+
writes.length === 1 && writes[0].op === "mutate" ? writes[0].ops : writes,
|
|
1482
|
+
[{ op: "set", path: ["askingHoldMs"], value: 2500 }]);
|
|
1483
|
+
|
|
1484
|
+
// A per-state edit rebuilds the whole `states` entry in the same mutate.
|
|
1485
|
+
// Idle has no row (F15b), so this uses Running.
|
|
1486
|
+
writes.length = 0;
|
|
1487
|
+
const runningRow = findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Running")[0];
|
|
1488
|
+
runningRow.props.onToggle(); // expand the running row (re-render is explicit)
|
|
1489
|
+
const runningOpenTree = card();
|
|
1490
|
+
const runningEffect = byId(runningOpenTree, "plugin-config-icon-running-effect");
|
|
1491
|
+
ok("F24 expanded state row exposes its effect select", !!runningEffect && runningEffect.props.value === "static",
|
|
1492
|
+
runningEffect && runningEffect.props.value);
|
|
1493
|
+
runningEffect.props.onChange("breath");
|
|
1494
|
+
findButton(card(), "Save").props.onClick();
|
|
1495
|
+
await tick();
|
|
1496
|
+
const stateOp = writes[0] && writes[0].ops && writes[0].ops[0];
|
|
1497
|
+
eq("F25 state edit saves the whole states object at its path",
|
|
1498
|
+
stateOp && stateOp.op === "set" && stateOp.path.join(".") + "=" + stateOp.value.running.effect,
|
|
1499
|
+
"states=breath");
|
|
1500
|
+
ok("F26 only edited states are written to the user layer",
|
|
1501
|
+
Object.keys(stateOp.value).length === 1 && !!stateOp.value.running,
|
|
1502
|
+
JSON.stringify(stateOp.value));
|
|
1503
|
+
|
|
1504
|
+
// A previously saved override for another state must survive an edit of a
|
|
1505
|
+
// different state: `scope.set('states', …)` replaces the whole field, so the
|
|
1506
|
+
// card rebuilds it from the raw user layer. An idle entry the card cannot
|
|
1507
|
+
// edit (e.g. hand-written into settings.yaml) must survive untouched too.
|
|
1508
|
+
writes.length = 0;
|
|
1509
|
+
snapshot.user = {
|
|
1510
|
+
states: {
|
|
1511
|
+
done: { effect: "rainbow", colors: ["#FF0000"] },
|
|
1512
|
+
idle: { effect: "breath", colors: ["#111111", "#DDDDDD"] },
|
|
1513
|
+
},
|
|
1514
|
+
};
|
|
1515
|
+
const runningAgain = byId(card(), "plugin-config-icon-running-effect");
|
|
1516
|
+
runningAgain.props.onChange("bounce");
|
|
1517
|
+
findButton(card(), "Save").props.onClick();
|
|
1518
|
+
await tick();
|
|
1519
|
+
const carried = writes[0] && writes[0].ops && writes[0].ops[0] && writes[0].ops[0].value;
|
|
1520
|
+
ok("F27 an existing user-layer override is carried through",
|
|
1521
|
+
carried && carried.done && carried.done.effect === "rainbow" && carried.running.effect === "bounce",
|
|
1522
|
+
JSON.stringify(carried));
|
|
1523
|
+
ok("F27b a hand-written idle entry survives untouched",
|
|
1524
|
+
carried && carried.idle && carried.idle.effect === "breath" && carried.idle.colors.length === 2,
|
|
1525
|
+
JSON.stringify(carried && carried.idle));
|
|
1526
|
+
|
|
1527
|
+
// ---- default color: field, live warning, one-atomic-mutate save -----------
|
|
1528
|
+
writes.length = 0;
|
|
1529
|
+
const defaultField = byId(card(), "plugin-config-icon-default-color");
|
|
1530
|
+
ok("F28 with no override configured the field shows the idle color chip",
|
|
1531
|
+
!!defaultField && Array.isArray(defaultField.props.colors) && defaultField.props.colors[0] === "#1a1a1a",
|
|
1532
|
+
JSON.stringify(defaultField && defaultField.props.colors));
|
|
1533
|
+
const paletteLabel = findAll(card(), (n) => n.props && n.props.children === "Palette")[0];
|
|
1534
|
+
const paletteRow = findAll(card(), (n) => Array.isArray(n.props && n.props.children) &&
|
|
1535
|
+
n.props.children.indexOf(paletteLabel) !== -1)[0];
|
|
1536
|
+
const paletteItems = (paletteRow && paletteRow.props.children[1]) || [];
|
|
1537
|
+
const paletteBands = (item) => item.props.children[1].props.children.map((b) => b.props.style.background);
|
|
1538
|
+
ok("F28b the palette lists every state as name-then-chip, one banded chip for asking",
|
|
1539
|
+
!!paletteLabel && paletteItems.length === 4 &&
|
|
1540
|
+
paletteItems.every((item) => typeof item.props.children[0] === "string" &&
|
|
1541
|
+
item.props.children[1] && Array.isArray(item.props.children[1].props.children)) &&
|
|
1542
|
+
paletteItems.some((item) => paletteBands(item).join(",") === "#E5484D,#FACC15"),
|
|
1543
|
+
JSON.stringify(paletteItems.map((item) => [item.props.children[0], paletteBands(item)])));
|
|
1544
|
+
// The card shows colors as chips; the hex value lives in the native picker.
|
|
1545
|
+
// No rendered text may contain a hex literal (warnings are the only place a
|
|
1546
|
+
// color code appears, and there is none configured at this point).
|
|
1547
|
+
ok("F28d the card renders colors as chips, never as hex text",
|
|
1548
|
+
!/#[0-9a-fA-F]{3,6}/.test(deepText(card())), deepText(card()).slice(0, 200));
|
|
1549
|
+
// Every native color picker is an invisible absolute overlay inside its 14x14
|
|
1550
|
+
// chip: without that style the UA widget renders at its own size and overflows
|
|
1551
|
+
// the row (the stray control seen next to the text field).
|
|
1552
|
+
// The stubbed React never invokes function components, so materialize the
|
|
1553
|
+
// ColorField elements before looking for their native color inputs.
|
|
1554
|
+
const colorFieldNodes = findAll(card(), (n) => n.props && typeof n.props.onColors === "function" && typeof n.props.id === "string");
|
|
1555
|
+
const colorInputs = colorFieldNodes
|
|
1556
|
+
.map((n) => n.type(n.props))
|
|
1557
|
+
.filter(Boolean)
|
|
1558
|
+
.reduce((acc, tree) => acc.concat(findAll(tree, (n) => n.type === "input" && n.props && n.props.type === "color")), []);
|
|
1559
|
+
ok("F28c every colour field keeps at least one native picker (5 chips today)",
|
|
1560
|
+
colorInputs.length === 5 &&
|
|
1561
|
+
colorFieldNodes.every((n) => findAll(n.type(n.props), (c) => c.type === "input" && c.props.type === "color").length >= 1),
|
|
1562
|
+
"inputs=" + colorInputs.length);
|
|
1563
|
+
ok("F28c every native color picker is an invisible overlay",
|
|
1564
|
+
colorInputs.length >= 1 && colorInputs.every((n) => n.props.style &&
|
|
1565
|
+
n.props.style.position === "absolute" && n.props.style.opacity === 0 &&
|
|
1566
|
+
n.props.style.width === "100%" && n.props.style.height === "100%"),
|
|
1567
|
+
JSON.stringify(colorInputs.map((n) => n.props.style)));
|
|
1568
|
+
|
|
1569
|
+
// Typing a color equal to the running state raises the strong warning live
|
|
1570
|
+
// (before any save) — advisory only, so Save stays enabled below.
|
|
1571
|
+
// Only the warning paragraphs (`role="status"`) count below — the palette row
|
|
1572
|
+
// renders the state names too, so `textOf` alone would be vacuous.
|
|
1573
|
+
const warnTexts = (tree) => findAll(tree, (n) => n.props && n.props.role === "status" && typeof n.props.children === "string")
|
|
1574
|
+
.map((n) => n.props.children);
|
|
1575
|
+
defaultField.props.onColors(["#FACC15"]);
|
|
1576
|
+
const warnedTree = card();
|
|
1577
|
+
ok("F29 a near-state color warns before saving",
|
|
1578
|
+
warnTexts(warnedTree).some((line) => line.includes("Nearly identical") && line.includes("#FACC15") && line.includes("Running")),
|
|
1579
|
+
JSON.stringify(warnTexts(warnedTree)));
|
|
1580
|
+
|
|
1581
|
+
findButton(warnedTree, "Save").props.onClick();
|
|
1582
|
+
await tick();
|
|
1583
|
+
const dcOps = (writes[0] && writes[0].ops) || [];
|
|
1584
|
+
ok("F30 save writes defaultColor in one atomic mutate despite the warning",
|
|
1585
|
+
writes.length === 1 &&
|
|
1586
|
+
dcOps.some((o) => o.op === "set" && o.path.join(".") === "defaultColor" && o.value === "#FACC15"),
|
|
1587
|
+
JSON.stringify(writes));
|
|
1588
|
+
|
|
1589
|
+
// A distinct color clears the warning again.
|
|
1590
|
+
const distinctField = byId(card(), "plugin-config-icon-default-color");
|
|
1591
|
+
distinctField.props.onColors(["#5B8DEF"]);
|
|
1592
|
+
ok("F31 a distinct color shows no warning", warnTexts(card()).length === 0, JSON.stringify(warnTexts(card())));
|
|
1593
|
+
|
|
1594
|
+
// ---- an override configured outside this card ----------------------------
|
|
1595
|
+
// The scope resolves `states` and `defaultColor` independently, so the
|
|
1596
|
+
// configured override (base or user layer) is NOT visible in
|
|
1597
|
+
// value.states.idle.colors[0]: the field, the palette and the warnings must
|
|
1598
|
+
// read the key itself.
|
|
1599
|
+
snapshot.user = {};
|
|
1600
|
+
snapshot.value.defaultColor = "#FACC15";
|
|
1601
|
+
// The stub only re-renders on a hook setState, so snapshot mutations must
|
|
1602
|
+
// land BEFORE this click (which also drops the '#5B8DEF' draft).
|
|
1603
|
+
findButton(card(), "Discard").props.onClick();
|
|
1604
|
+
const overrideField = byId(card(), "plugin-config-icon-default-color");
|
|
1605
|
+
ok("F32 the field shows the configured defaultColor chip, not the scope's idle color",
|
|
1606
|
+
overrideField.props.colors[0] === "#FACC15", JSON.stringify(overrideField.props.colors));
|
|
1607
|
+
ok("F33 the configured defaultColor is what the warnings judge",
|
|
1608
|
+
warnTexts(card()).some((line) => line.includes("Running") && line.includes("#FACC15")),
|
|
1609
|
+
JSON.stringify(warnTexts(card())));
|
|
1610
|
+
|
|
1611
|
+
// A default-color edit and a state edit land in ONE atomic mutate.
|
|
1612
|
+
snapshot.user = {};
|
|
1613
|
+
snapshot.value.defaultColor = "#FACC15";
|
|
1614
|
+
writes.length = 0;
|
|
1615
|
+
byId(card(), "plugin-config-icon-default-color").props.onColors(["#5B8DEF"]);
|
|
1616
|
+
byId(card(), "plugin-config-icon-running-effect").props.onChange("blink");
|
|
1617
|
+
findButton(card(), "Save").props.onClick();
|
|
1618
|
+
await tick();
|
|
1619
|
+
const combinedOps = (writes[0] && writes[0].ops) || [];
|
|
1620
|
+
ok("F34 a default-color edit and a state edit share one atomic mutate",
|
|
1621
|
+
writes.length === 1 &&
|
|
1622
|
+
combinedOps.some((o) => o.op === "set" && o.path.join(".") === "defaultColor" && o.value === "#5B8DEF") &&
|
|
1623
|
+
combinedOps.some((o) => o.op === "set" && o.path.join(".") === "states"),
|
|
1624
|
+
JSON.stringify(writes));
|
|
1625
|
+
|
|
1626
|
+
// Editing a state must never touch the default color: idle is not a card
|
|
1627
|
+
// state, so no idle row can drag the override into the write.
|
|
1628
|
+
snapshot.value.defaultColor = "#FACC15";
|
|
1629
|
+
writes.length = 0;
|
|
1630
|
+
byId(card(), "plugin-config-icon-running-colors").props.onColors(["#3366FF"]);
|
|
1631
|
+
findButton(card(), "Save").props.onClick();
|
|
1632
|
+
await tick();
|
|
1633
|
+
const stateOnlyOps = (writes[0] && writes[0].ops) || [];
|
|
1634
|
+
ok("F35 a state edit leaves the default color alone",
|
|
1635
|
+
stateOnlyOps.some((o) => o.op === "set" && o.path.join(".") === "states") &&
|
|
1636
|
+
!stateOnlyOps.some((o) => o.path.join(".") === "defaultColor"),
|
|
1637
|
+
JSON.stringify(writes));
|
|
1638
|
+
|
|
1639
|
+
// Host parity for malformed state colors: resolveConfig drops them and falls
|
|
1640
|
+
// back to the built-in color, so the card must judge that same color.
|
|
1641
|
+
snapshot.value.states = Object.assign({}, snapshot.value.states, { running: { effect: "static", colors: ["red"] } });
|
|
1642
|
+
snapshot.value.defaultColor = "#FACC15";
|
|
1643
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // force a render
|
|
1644
|
+
ok("F36 the card falls back to the built-in state color like the host",
|
|
1645
|
+
warnTexts(card()).some((line) => line.includes("Running") && line.includes("#FACC15")),
|
|
1646
|
+
JSON.stringify(warnTexts(card())));
|
|
1647
|
+
|
|
1648
|
+
// ---- only the colors the effect uses are listed (and saved) ---------------
|
|
1649
|
+
const askingColorsBefore = byId(card(), "plugin-config-icon-asking-colors");
|
|
1650
|
+
ok("F37 a two-color effect (blink) lists both colors and has no room to add",
|
|
1651
|
+
askingColorsBefore.props.colors.join(",") === "#E5484D,#FACC15" && askingColorsBefore.props.canAdd === false,
|
|
1652
|
+
JSON.stringify({ colors: askingColorsBefore.props.colors, canAdd: askingColorsBefore.props.canAdd }));
|
|
1653
|
+
|
|
1654
|
+
// blink -> static: the unused second color must disappear from the field...
|
|
1655
|
+
byId(card(), "plugin-config-icon-asking-effect").props.onChange("static");
|
|
1656
|
+
const singleField = byId(card(), "plugin-config-icon-asking-colors");
|
|
1657
|
+
ok("F38 a single-color effect shows exactly one chip and no add chip",
|
|
1658
|
+
singleField.props.colors.join(",") === "#E5484D" && singleField.props.canAdd === false,
|
|
1659
|
+
JSON.stringify({ colors: singleField.props.colors, canAdd: singleField.props.canAdd }));
|
|
1660
|
+
// ...and from the stored config: no invisible second color survives the save.
|
|
1661
|
+
writes.length = 0;
|
|
1662
|
+
findButton(card(), "Save").props.onClick();
|
|
1663
|
+
await tick();
|
|
1664
|
+
const statesWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
|
|
1665
|
+
ok("F38b saving a single-color effect drops the unused second color",
|
|
1666
|
+
!!statesWrite && statesWrite.value.asking.effect === "static" &&
|
|
1667
|
+
statesWrite.value.asking.colors.join(",") === "#E5484D",
|
|
1668
|
+
JSON.stringify(statesWrite && statesWrite.value.asking));
|
|
1669
|
+
|
|
1670
|
+
// rainbow needs no color configuration: the field and the row icon show the
|
|
1671
|
+
// hue wheel instead of pickers.
|
|
1672
|
+
byId(card(), "plugin-config-icon-asking-effect").props.onChange("rainbow");
|
|
1673
|
+
const rainbowField = byId(card(), "plugin-config-icon-asking-colors");
|
|
1674
|
+
const rainbowIcon = findAll(card(), (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Asking")[0].props.icon;
|
|
1675
|
+
const rainbowTree = rainbowField.type(rainbowField.props);
|
|
1676
|
+
// The stub collapses a single child to the child itself, so normalize it.
|
|
1677
|
+
const iconLayers = Array.isArray(rainbowIcon.props.children) ? rainbowIcon.props.children : [rainbowIcon.props.children];
|
|
1678
|
+
const rainbowInputs = findAll(rainbowTree, (n) => n.type === "input" && n.props.type === "color");
|
|
1679
|
+
ok("F39 rainbow shows the hue wheel plus one optional starting-hue chip",
|
|
1680
|
+
rainbowField.props.rainbow === true &&
|
|
1681
|
+
// exactly one colour is carried: the hue seed the browser sweeps from
|
|
1682
|
+
rainbowField.props.colors.length === 1 && rainbowField.props.canAdd === false &&
|
|
1683
|
+
rainbowInputs.length === 1 &&
|
|
1684
|
+
// no add / remove affordances for a colour list that does not exist
|
|
1685
|
+
findAll(rainbowTree, (n) => n.type === "button").length === 0 &&
|
|
1686
|
+
// the field preview is a 20px disc
|
|
1687
|
+
findAll(rainbowTree, (n) => n.props && n.props.style && n.props.style.borderRadius === 999 &&
|
|
1688
|
+
n.props.style.width === 20 && n.props.style.height === 20).length === 1 &&
|
|
1689
|
+
iconLayers.length === 1 &&
|
|
1690
|
+
String(iconLayers[0].props.style.background).indexOf("linear-gradient(135deg,") === 0 &&
|
|
1691
|
+
String(iconLayers[0].props.style.backgroundImage).indexOf("conic-gradient(from 0deg,") === 0,
|
|
1692
|
+
JSON.stringify({
|
|
1693
|
+
rainbow: rainbowField.props.rainbow,
|
|
1694
|
+
colors: rainbowField.props.colors,
|
|
1695
|
+
inputs: rainbowInputs.length,
|
|
1696
|
+
buttons: findAll(rainbowTree, (n) => n.type === "button").length,
|
|
1697
|
+
icon: iconLayers[0] && iconLayers[0].props.style.background,
|
|
1698
|
+
}));
|
|
1699
|
+
|
|
1700
|
+
// The starting-hue chip rewrites colors[0] alone (rainbow never carries a
|
|
1701
|
+
// second colour), so the sweep begins at the picked hue.
|
|
1702
|
+
rainbowInputs[0].props.onChange({ target: { value: "#0066ff" } });
|
|
1703
|
+
writes.length = 0;
|
|
1704
|
+
findButton(card(), "Save").props.onClick();
|
|
1705
|
+
await tick();
|
|
1706
|
+
const seedWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
|
|
1707
|
+
ok("F39b the starting-hue chip saves a single-colour rainbow state",
|
|
1708
|
+
!!seedWrite && seedWrite.value.asking.effect === "rainbow" &&
|
|
1709
|
+
seedWrite.value.asking.colors.join(",") === "#0066ff",
|
|
1710
|
+
JSON.stringify(seedWrite && seedWrite.value.asking));
|
|
1711
|
+
|
|
1712
|
+
// rainbow keeps exactly one stored colour: the hue seed the browser's
|
|
1713
|
+
// frameColor() starts from. Switching a two-colour state (asking is blink
|
|
1714
|
+
// with #E5484D ⇄ #FACC15) to rainbow must TRIM the stored list to that one
|
|
1715
|
+
// seed — this is the save-side half of "only what the effect uses is kept".
|
|
1716
|
+
byId(card(), "plugin-config-icon-asking-effect").props.onChange("rainbow");
|
|
1717
|
+
writes.length = 0;
|
|
1718
|
+
findButton(card(), "Save").props.onClick();
|
|
1719
|
+
await tick();
|
|
1720
|
+
const rainbowWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
|
|
1721
|
+
ok("F40 switching blink -> rainbow trims the stored colours to the single hue seed",
|
|
1722
|
+
!!rainbowWrite && rainbowWrite.value.asking.effect === "rainbow" &&
|
|
1723
|
+
rainbowWrite.value.asking.colors.join(",") === "#E5484D",
|
|
1724
|
+
JSON.stringify(rainbowWrite && rainbowWrite.value.asking));
|
|
1725
|
+
|
|
1726
|
+
// The card duplicates the host's built-in per-state colours (it cannot import
|
|
1727
|
+
// lib/index.js). Pin the copy to plugin.config.states so the two can never
|
|
1728
|
+
// drift: a drift would make the card warn about a colour the host never paints.
|
|
1729
|
+
let cardStates = null;
|
|
1730
|
+
try {
|
|
1731
|
+
// extractBlock() balances braces, so a reformat or a nested literal cannot
|
|
1732
|
+
// break this the way a hand-rolled substring slice would.
|
|
1733
|
+
const block = extractBlock(CLIENT_SRC, "var DEFAULT_STATES = ");
|
|
1734
|
+
cardStates = eval("(" + block.slice(block.indexOf("{")) + ")");
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
cardStates = null; // reported as a failure below, never an aborted suite
|
|
1737
|
+
}
|
|
1738
|
+
// The ΔE thresholds and the accepted-hex regex are duplicated by design (no
|
|
1739
|
+
// shared module, no build step): pin them together so tuning one half cannot
|
|
1740
|
+
// silently change the other's verdicts.
|
|
1741
|
+
const constOf = (src, name) => {
|
|
1742
|
+
const m = src.match(new RegExp("(?:const|let|var)\\s+" + name + "\\s*=\\s*([^;]+);"));
|
|
1743
|
+
return m ? m[1].trim() : null;
|
|
1744
|
+
};
|
|
1745
|
+
const sharedConstants = ["SIMILAR_DE_STRONG", "SIMILAR_DE_WARN", "RAINBOW_CHROMA_MIN", "DEFAULT_COLOR_RX"];
|
|
1746
|
+
ok("F42 the duplicated thresholds / hex regex match between the two halves",
|
|
1747
|
+
sharedConstants.every((n) => constOf(SRC, n) !== null && constOf(SRC, n) === constOf(CLIENT_SRC, n)),
|
|
1748
|
+
JSON.stringify(sharedConstants.map((n) => [n, constOf(SRC, n), constOf(CLIENT_SRC, n)])));
|
|
1749
|
+
|
|
1750
|
+
ok("F41 the card's built-in state table mirrors the host DEFAULTS (effect + colors + speed)",
|
|
1751
|
+
!!cardStates &&
|
|
1752
|
+
Object.keys(cardStates).length === Object.keys(plugin.config.states).length &&
|
|
1753
|
+
Object.keys(cardStates).every((k) => {
|
|
1754
|
+
const host = plugin.config.states[k];
|
|
1755
|
+
return cardStates[k].effect === host.effect &&
|
|
1756
|
+
JSON.stringify(cardStates[k].colors) === JSON.stringify(host.colors) &&
|
|
1757
|
+
cardStates[k].speed === host.speed;
|
|
1758
|
+
}),
|
|
1759
|
+
JSON.stringify({ card: cardStates, host: plugin.config.states }));
|
|
1760
|
+
|
|
1761
|
+
// ---- the "+" chip: derived seed, no duplicates, room-only -----------------
|
|
1762
|
+
// done is static (one colour) and has room for a second; switching it to
|
|
1763
|
+
// breath is the exact state that should offer the add chip.
|
|
1764
|
+
byId(card(), "plugin-config-icon-done-effect").props.onChange("breath");
|
|
1765
|
+
const addField = byId(card(), "plugin-config-icon-done-colors");
|
|
1766
|
+
ok("F43 a single-colour list has room, so the add chip appears",
|
|
1767
|
+
addField.props.canAdd === true && addField.props.colors.join(",") === "#22A06B",
|
|
1768
|
+
JSON.stringify({ canAdd: addField.props.canAdd, colors: addField.props.colors }));
|
|
1769
|
+
const addTree = addField.type(addField.props);
|
|
1770
|
+
const addInputs = findAll(addTree, (n) => n.type === "input" && n.props.type === "color");
|
|
1771
|
+
// The add picker opens on the colour the browser derives anyway
|
|
1772
|
+
// (mix(#22A06B, black, 35%)), so confirming it as-is is meaningful.
|
|
1773
|
+
ok("F43b the add picker opens on the derived second colour",
|
|
1774
|
+
addInputs.length === 2 && addInputs[1].props.value === "#166846",
|
|
1775
|
+
JSON.stringify(addInputs.map((i) => i.props.value)));
|
|
1776
|
+
// Re-materialize before every edit: a handler captured from an earlier tree
|
|
1777
|
+
// closes over the OLD colour list, which would mask a real append bug.
|
|
1778
|
+
const addInputsOf = () => {
|
|
1779
|
+
const field = byId(card(), "plugin-config-icon-done-colors");
|
|
1780
|
+
return findAll(field.type(field.props), (n) => n.type === "input" && n.props.type === "color");
|
|
1781
|
+
};
|
|
1782
|
+
addInputsOf()[1].props.onChange({ target: { value: "#22a06b" } }); // already present, lower-case
|
|
1783
|
+
ok("F43c an already-present colour is not appended",
|
|
1784
|
+
byId(card(), "plugin-config-icon-done-colors").props.colors.length === 1,
|
|
1785
|
+
JSON.stringify(byId(card(), "plugin-config-icon-done-colors").props.colors));
|
|
1786
|
+
addInputsOf()[1].props.onChange({ target: { value: "#00c8ff" } });
|
|
1787
|
+
ok("F43d a new colour is appended as the second one",
|
|
1788
|
+
byId(card(), "plugin-config-icon-done-colors").props.colors.join(",") === "#22A06B,#00c8ff",
|
|
1789
|
+
JSON.stringify(byId(card(), "plugin-config-icon-done-colors").props.colors));
|
|
1790
|
+
|
|
1791
|
+
// ---- reset also neutralizes a COMPOSITION-ENTRY defaultColor --------------
|
|
1792
|
+
// `scope.unset` cannot reach the base layer, so a base-provided defaultColor
|
|
1793
|
+
// would survive "Reset to defaults" and the field would look untouched.
|
|
1794
|
+
snapshot.base = { defaultColor: "#FACC15" };
|
|
1795
|
+
snapshot.user = {};
|
|
1796
|
+
snapshot.value.defaultColor = "#FACC15";
|
|
1797
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // force a render
|
|
1798
|
+
writes.length = 0;
|
|
1799
|
+
findButton(card(), "Reset to defaults").props.onClick();
|
|
1800
|
+
await tick();
|
|
1801
|
+
const idleOwnColor = snapshot.value.states.idle.colors[0];
|
|
1802
|
+
const resetOps = writes.map((w) => w.op + ":" + w.field + (w.value !== undefined ? "=" + w.value : ""));
|
|
1803
|
+
ok("F44 a reset rewrites a base-layer defaultColor to the idle colour",
|
|
1804
|
+
resetOps.indexOf("set:defaultColor=" + idleOwnColor) !== -1 &&
|
|
1805
|
+
resetOps.indexOf("unset:states") !== -1,
|
|
1806
|
+
JSON.stringify(resetOps));
|
|
1807
|
+
|
|
1808
|
+
// ---- clear-override persists as an unset ----------------------------------
|
|
1809
|
+
snapshot.base = {};
|
|
1810
|
+
snapshot.user = { defaultColor: "#FACC15" };
|
|
1811
|
+
snapshot.value.defaultColor = "#FACC15";
|
|
1812
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // force a render
|
|
1813
|
+
const clearField = byId(card(), "plugin-config-icon-default-color");
|
|
1814
|
+
ok("F45 the clear-override control is offered for a user-layer value",
|
|
1815
|
+
typeof clearField.props.onClear === "function", String(clearField.props.clearLabel));
|
|
1816
|
+
clearField.props.onClear();
|
|
1817
|
+
writes.length = 0;
|
|
1818
|
+
findButton(card(), "Save").props.onClick();
|
|
1819
|
+
await tick();
|
|
1820
|
+
const clearOps = ((writes[0] && writes[0].ops) || []);
|
|
1821
|
+
ok("F45b clearing the override saves an unset of defaultColor",
|
|
1822
|
+
clearOps.some((o) => o.op === "unset" && o.path.join(".") === "defaultColor"),
|
|
1823
|
+
JSON.stringify(writes));
|
|
1824
|
+
|
|
1825
|
+
// ---- a save with nothing to write settles the form ------------------------
|
|
1826
|
+
snapshot.user = {};
|
|
1827
|
+
snapshot.value.defaultColor = null;
|
|
1828
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // same as resolved
|
|
1829
|
+
writes.length = 0;
|
|
1830
|
+
const noopSave = findButton(card(), "Save");
|
|
1831
|
+
ok("F46 a draft equal to the resolved value keeps Save enabled", noopSave.props.disabled === false);
|
|
1832
|
+
noopSave.props.onClick();
|
|
1833
|
+
await tick();
|
|
1834
|
+
ok("F46b a no-op save writes nothing and clears the draft",
|
|
1835
|
+
writes.length === 0 && findButton(card(), "Save").props.disabled === true,
|
|
1836
|
+
JSON.stringify({ writes, disabled: findButton(card(), "Save").props.disabled }));
|
|
1837
|
+
|
|
1838
|
+
// ---- the rainbow warning needs a CHROMATIC default ------------------------
|
|
1839
|
+
snapshot.value.defaultColor = "#1a1a1a"; // neutral: no hue to collide with
|
|
1840
|
+
snapshot.value.states.done = { effect: "rainbow", colors: ["#22A06B"] };
|
|
1841
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1842
|
+
const neutralLines = warnTexts(card());
|
|
1843
|
+
ok("F47 a neutral default colour raises no rainbow warning",
|
|
1844
|
+
neutralLines.length === 0, JSON.stringify(neutralLines));
|
|
1845
|
+
snapshot.value.defaultColor = "#E5484D";
|
|
1846
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1847
|
+
ok("F47b a chromatic default colour raises the rainbow warning",
|
|
1848
|
+
warnTexts(card()).some((line) => line.includes("rainbow")), JSON.stringify(warnTexts(card())));
|
|
1849
|
+
|
|
1850
|
+
// ---- the warning judges the fills the effect actually paints --------------
|
|
1851
|
+
// blink with a single colour: the browser derives mix(c0, #000, 35%).
|
|
1852
|
+
snapshot.value.defaultColor = "#952F32";
|
|
1853
|
+
snapshot.value.states.asking = { effect: "blink", colors: ["#E5484D"], speed: 400 };
|
|
1854
|
+
snapshot.value.states.done = { effect: "static", colors: ["#22A06B"] };
|
|
1855
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1856
|
+
ok("F48 the derived second fill of a one-colour blink is compared",
|
|
1857
|
+
warnTexts(card()).some((line) => line.includes("Asking") && line.includes("#952f32")),
|
|
1858
|
+
JSON.stringify(warnTexts(card())));
|
|
1859
|
+
// breath interpolates: the midpoint of #E5484D ⇄ #FFFFFF is a real frame.
|
|
1860
|
+
snapshot.value.defaultColor = "#F2A4A6";
|
|
1861
|
+
snapshot.value.states.asking = { effect: "breath", colors: ["#E5484D", "#FFFFFF"] };
|
|
1862
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1863
|
+
ok("F48b the breath interpolation is sampled",
|
|
1864
|
+
warnTexts(card()).some((line) => line.includes("Asking") && line.includes("#f2a4a6")),
|
|
1865
|
+
JSON.stringify(warnTexts(card())));
|
|
1866
|
+
|
|
1867
|
+
// ---- a partially invalid stored list keeps its valid entries --------------
|
|
1868
|
+
snapshot.value.defaultColor = "#FF0000";
|
|
1869
|
+
snapshot.value.states.asking = { effect: "static", colors: ["#FF0000", "red"] };
|
|
1870
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1871
|
+
const partialField = byId(card(), "plugin-config-icon-asking-colors");
|
|
1872
|
+
const partialLines = warnTexts(card());
|
|
1873
|
+
ok("F49 the card keeps the valid entries of a partially invalid list",
|
|
1874
|
+
partialField.props.colors.join(",") === "#FF0000" &&
|
|
1875
|
+
partialLines.some((line) => line.includes("Asking") && line.toLowerCase().includes("#ff0000")) &&
|
|
1876
|
+
!partialLines.some((line) => line.toLowerCase().includes("#facc15")),
|
|
1877
|
+
JSON.stringify({ colors: partialField.props.colors, lines: partialLines }));
|
|
1878
|
+
|
|
1879
|
+
// ---- a PARTIAL resolved states dict must read like what the host paints ----
|
|
1880
|
+
// The card writes only the states it drafted, so `value.states` really can
|
|
1881
|
+
// omit `asking`; the host still blinks it with the built-in config, and the
|
|
1882
|
+
// card used to show "Static" + a dashed chip there — then SAVE that "static"
|
|
1883
|
+
// over the user's config on the next colour edit.
|
|
1884
|
+
snapshot.base = {};
|
|
1885
|
+
snapshot.user = { states: { running: { effect: "bounce", colors: ["#FF0000"] } } };
|
|
1886
|
+
snapshot.value = {
|
|
1887
|
+
askingHoldMs: 3500,
|
|
1888
|
+
doneHoldMs: 5000,
|
|
1889
|
+
defaultColor: null,
|
|
1890
|
+
states: { running: { effect: "bounce", colors: ["#FF0000"] } },
|
|
1891
|
+
};
|
|
1892
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // force a render
|
|
1893
|
+
ok("F50 a state missing from the resolved dict falls back to the built-in config",
|
|
1894
|
+
byId(card(), "plugin-config-icon-asking-effect").props.value === "blink" &&
|
|
1895
|
+
byId(card(), "plugin-config-icon-asking-colors").props.colors.join(",") === "#E5484D,#FACC15",
|
|
1896
|
+
JSON.stringify({
|
|
1897
|
+
effect: byId(card(), "plugin-config-icon-asking-effect").props.value,
|
|
1898
|
+
colors: byId(card(), "plugin-config-icon-asking-colors").props.colors,
|
|
1899
|
+
}));
|
|
1900
|
+
writes.length = 0;
|
|
1901
|
+
byId(card(), "plugin-config-icon-asking-colors").props.onColors(["#123456", "#FACC15"]);
|
|
1902
|
+
findButton(card(), "Save").props.onClick();
|
|
1903
|
+
await tick();
|
|
1904
|
+
const partialWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
|
|
1905
|
+
ok("F50b a colour edit on that state keeps the built-in effect",
|
|
1906
|
+
!!partialWrite && partialWrite.value.asking.effect === "blink" &&
|
|
1907
|
+
partialWrite.value.asking.colors.join(",") === "#123456,#FACC15",
|
|
1908
|
+
JSON.stringify(partialWrite && partialWrite.value.asking));
|
|
1909
|
+
|
|
1910
|
+
// ---- those two host behaviours again, on the card side --------------------
|
|
1911
|
+
snapshot.user = {};
|
|
1912
|
+
snapshot.value = {
|
|
1913
|
+
askingHoldMs: 3500,
|
|
1914
|
+
doneHoldMs: 5000,
|
|
1915
|
+
defaultColor: "#005aa5",
|
|
1916
|
+
states: { running: { effect: "breath", colors: ["#0000ff", "#00ff00"] } },
|
|
1917
|
+
};
|
|
1918
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1919
|
+
const breathLine = warnTexts(card()).find((line) => line.includes("Running"));
|
|
1920
|
+
const breathDe = breathLine ? Number((breathLine.match(/ΔE ([0-9.]+)/) || [])[1]) : NaN;
|
|
1921
|
+
ok("F51 the card samples the breath gradient as densely as the host",
|
|
1922
|
+
!!breathLine && breathDe < 8, JSON.stringify({ breathLine, breathDe }));
|
|
1923
|
+
|
|
1924
|
+
snapshot.value = {
|
|
1925
|
+
askingHoldMs: 3500,
|
|
1926
|
+
doneHoldMs: 5000,
|
|
1927
|
+
defaultColor: "#ff0000",
|
|
1928
|
+
states: { idle: { effect: "rainbow", colors: ["#1a1a1a"] } },
|
|
1929
|
+
};
|
|
1930
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1931
|
+
const idleRainbowLines = warnTexts(card());
|
|
1932
|
+
ok("F52 idle running rainbow warns on the card side too (host parity)",
|
|
1933
|
+
idleRainbowLines.length === 1 && idleRainbowLines[0].includes("rainbow") && idleRainbowLines[0].includes("Idle"),
|
|
1934
|
+
JSON.stringify(idleRainbowLines));
|
|
1935
|
+
|
|
1936
|
+
// ---- reset leaves the deployment value one click away ---------------------
|
|
1937
|
+
snapshot.base = { defaultColor: "#FACC15" };
|
|
1938
|
+
snapshot.user = {};
|
|
1939
|
+
snapshot.value = { askingHoldMs: 3500, doneHoldMs: 5000, defaultColor: "#FACC15", states: {} };
|
|
1940
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1941
|
+
const baseOnlyField = byId(card(), "plugin-config-icon-default-color");
|
|
1942
|
+
ok("F53 a base-layer value offers no clear-override control",
|
|
1943
|
+
!baseOnlyField.props.onClear, String(baseOnlyField.props.clearLabel));
|
|
1944
|
+
writes.length = 0;
|
|
1945
|
+
findButton(card(), "Reset to defaults").props.onClick();
|
|
1946
|
+
await tick();
|
|
1947
|
+
// The reset writes an explicit user-layer value (the idle colour), so the
|
|
1948
|
+
// control that takes the user back to the deployment value comes back.
|
|
1949
|
+
snapshot.user = { defaultColor: "#1a1a1a" };
|
|
1950
|
+
snapshot.value.defaultColor = "#1a1a1a";
|
|
1951
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1952
|
+
ok("F53b after the reset the clear-override control reappears",
|
|
1953
|
+
typeof byId(card(), "plugin-config-icon-default-color").props.onClear === "function",
|
|
1954
|
+
String(byId(card(), "plugin-config-icon-default-color").props.clearLabel));
|
|
1955
|
+
|
|
1956
|
+
// ---- the cycle field follows the effect -----------------------------------
|
|
1957
|
+
snapshot.base = {};
|
|
1958
|
+
snapshot.user = {};
|
|
1959
|
+
snapshot.value = { askingHoldMs: 3500, doneHoldMs: 5000, defaultColor: null, states: {} };
|
|
1960
|
+
byId(card(), "plugin-config-icon-asking-effect").props.onChange("blink");
|
|
1961
|
+
const speedField = byId(card(), "plugin-config-icon-asking-speed");
|
|
1962
|
+
ok("F54 an animated state exposes its cycle field, seeded from the built-in default",
|
|
1963
|
+
!!speedField && speedField.props.value === "400",
|
|
1964
|
+
JSON.stringify(speedField && speedField.props.value));
|
|
1965
|
+
speedField.props.onChange("");
|
|
1966
|
+
writes.length = 0;
|
|
1967
|
+
findButton(card(), "Save").props.onClick();
|
|
1968
|
+
await tick();
|
|
1969
|
+
const speedWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
|
|
1970
|
+
ok("F54b a blank cycle drops the key (the built-in cycle applies again)",
|
|
1971
|
+
!!speedWrite && speedWrite.value.asking.speed === undefined &&
|
|
1972
|
+
speedWrite.value.asking.effect === "blink",
|
|
1973
|
+
JSON.stringify(speedWrite && speedWrite.value.asking));
|
|
1974
|
+
byId(card(), "plugin-config-icon-done-effect").props.onChange("static");
|
|
1975
|
+
ok("F54c a static effect hides the cycle field",
|
|
1976
|
+
!byId(card(), "plugin-config-icon-done-speed"));
|
|
1977
|
+
|
|
1978
|
+
// ---- the add chip's edge cases the mutation run proved unpinned -------------
|
|
1979
|
+
// #0c8 and #00cc88 are the SAME colour: picking the long spelling must not
|
|
1980
|
+
// append a duplicate band.
|
|
1981
|
+
snapshot.user = {};
|
|
1982
|
+
snapshot.value = {
|
|
1983
|
+
askingHoldMs: 3500,
|
|
1984
|
+
doneHoldMs: 5000,
|
|
1985
|
+
defaultColor: null,
|
|
1986
|
+
states: { done: { effect: "breath", colors: ["#0c8"] } },
|
|
1987
|
+
};
|
|
1988
|
+
// F54c left an unsaved "static" draft on done -> drop it, or the field would
|
|
1989
|
+
// render for that draft instead of the stored breath effect.
|
|
1990
|
+
findButton(card(), "Discard").props.onClick();
|
|
1991
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
1992
|
+
const addInputsNow = () => {
|
|
1993
|
+
const field = byId(card(), "plugin-config-icon-done-colors");
|
|
1994
|
+
return findAll(field.type(field.props), (n) => n.type === "input" && n.props.type === "color");
|
|
1995
|
+
};
|
|
1996
|
+
addInputsNow()[1].props.onChange({ target: { value: "#00cc88" } });
|
|
1997
|
+
ok("F55 a 3-digit spelling counts as the same colour",
|
|
1998
|
+
byId(card(), "plugin-config-icon-done-colors").props.colors.length === 1,
|
|
1999
|
+
JSON.stringify(byId(card(), "plugin-config-icon-done-colors").props.colors));
|
|
2000
|
+
|
|
2001
|
+
// An unusable stored colour shows the dashed replacement chip and NO add chip:
|
|
2002
|
+
// "add a second color" is nonsense with zero colours.
|
|
2003
|
+
snapshot.value = {
|
|
2004
|
+
askingHoldMs: 3500,
|
|
2005
|
+
doneHoldMs: 5000,
|
|
2006
|
+
defaultColor: null,
|
|
2007
|
+
states: { done: { effect: "breath", colors: ["nope"] } },
|
|
2008
|
+
};
|
|
2009
|
+
findButton(card(), "Discard").props.onClick(); // drop anything F55 left behind
|
|
2010
|
+
byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
|
|
2011
|
+
const emptyField = byId(card(), "plugin-config-icon-done-colors");
|
|
2012
|
+
const emptyTree = emptyField.type(emptyField.props);
|
|
2013
|
+
ok("F56 an unusable colour list offers the dashed chip but no add chip",
|
|
2014
|
+
emptyField.props.colors.length === 0 && emptyField.props.canAdd === false &&
|
|
2015
|
+
findAll(emptyTree, (n) => n.type === "input" && n.props.type === "color").length === 1,
|
|
2016
|
+
JSON.stringify({ colors: emptyField.props.colors, canAdd: emptyField.props.canAdd }));
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// ---- summary ---------------------------------------------------------------
|
|
2020
|
+
console.log("\n" + "=".repeat(60));
|
|
2021
|
+
console.log(`result: ${passed} passed, ${failed} failed`);
|
|
2022
|
+
if (failures.length) {
|
|
2023
|
+
console.log("failures:");
|
|
2024
|
+
for (const f of failures) console.log(" - " + f);
|
|
2025
|
+
}
|
|
2026
|
+
process.exit(failed ? 1 : 0);
|