Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_triggers.mjs

34.8 KiB, 1 run

created by r2519314175:761, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_triggers.mjs — phase H: automation you can see, pause and read.
2//
3// Notes2 §Diamonds asks for triggered actions, a Pending panel and two default
4// Diamonds. The properties below are chosen because each one is a thing that
5// would be invisible if it were wrong:
6//
7// 0. THE CLOCK. Every timer keeps its own stopwatch, counts only the minutes
8// somebody actually worked in, starts at the release of a hold, and keeps
9// what a refused firing accrued instead of throwing it away. It runs in
10// node against `www/js/triggers.js` with no browser at all, which is what
11// lets it advance half an hour in a millisecond -- and it was written after
12// two faults the seven checks below had no way of seeing: one stopwatch
13// shared by the whole account, so the shortest timer starved every longer
14// one, and a reset that happened whether or not the turn did.
15//
16// 1. The two default Diamonds are there. The Optimiser STARTS PAUSED, because
17// it carries a timer that would otherwise spend on a schedule nobody set.
18// Help does NOT, and carries no light at all: it has no triggered actions,
19// so the only thing that makes it spend is the user typing to it.
20// 2. The Optimiser's timer action is there and is INACTIVE, in the record and
21// on the pause tree both — notes2 asks for it by name.
22// 3. A trigger is a leaf of the pause tree, so a Diamond with one held and its
23// daimon running reads amber without anyone setting amber.
24// 4. EVERY action has a light. There used to be one that did not — `prompted`,
25// on every Diamond, drawn with a spacer where the widget goes — and the
26// user's ruling removed it: typing a prompt IS the activation, so there is
27// nothing there to arm and nothing to represent.
28// 5. The actions are FILES, at `diamonds/<id>/triggers.json`, where the System
29// section shows them and a daimon can read them.
30// 6. Context is sent ONCE, and changing it makes it new again. Measured on the
31// composer, which is the thing that decides.
32// 7. A HELD DIAMOND'S INPUT DOES NOT CLAIM IT WILL NOT WORK — and the turn it
33// is typed into goes through. This check used to assert the OPPOSITE: that
34// the box reads "Paused. Press play on its tile". That sentence was false,
35// and this file specifying it is why it lived so long. Nothing refuses a
36// turn on the daimon's `/self` leaf — the placeholder was its only reader in
37// the whole app — so a held Diamond has always answered a typed message
38// exactly as a running one does. The Optimiser ships held, per (1), so it
39// said so from the day it was made, to a user who could type perfectly well.
40// Holding a Diamond means it will not act UNBIDDEN; it has never meant the
41// user cannot talk to it, and `pauseWidget`'s own seeding code says as much
42// when it refuses to hold a Diamond with no triggers.
43// 8. A timer that is REFUSED keeps what it accrued. The three refusals are
44// silent -- no model, a turn already running, a Diamond that is not the one
45// on screen -- so this drives the real tick with the Diamond off screen and
46// then puts it on screen, and watches the same accrued time arrive at a
47// turn rather than being spent on nothing.
48//
49// Plus the Pending panel: three answers, all three taking the tile away, and the
50// sort the user chose.
51//
52// node dev/verify_triggers.mjs
53// node dev/verify_triggers.mjs --clock # section 0 alone, no browser
54// node dev/verify_triggers.mjs --break unpaused # defaults arrive running
55// node dev/verify_triggers.mjs --break ctxtwice # context is sent every time
56// node dev/verify_triggers.mjs --break eagerreset # the tick zeroes a refused timer
57//
58// and the clock's own, each of which is how some piece of it used to behave or
59// could plausibly be written:
60//
61// --clock --break sharedclock one stopwatch for the whole account
62// --clock --break deaf signs of life never reach the clock
63// --clock --break neverreset a clock that is never restarted
64// --clock --break unheld the tree answering that nothing is ever held
65// --clock --break wallclock the input gate off, so an untouched tab counts
66// --clock --break readsclock `due` consults the clock instead of the occasion
67// --clock --break openhanded no reading read as "everything", not "nothing"
68//
69// Every break replaces a piece of the CODE UNDER TEST with the way it behaved
70// before the fix, never a piece of this file, and every check in section 0 is
71// red under at least one of them. A check nobody can turn red is not a check.
72//
73// Needs dev/serve.mjs and dev/mockllm.mjs (dev/world.sh N --up gives both) --
74// except with `--clock`, which needs neither.
75import fs from 'node:fs';
76import path from 'node:path';
77import os from 'node:os';
78import { createRequire } from 'node:module';
79
80// `--clock` runs section 0 by itself, and section 0 is plain node. So the
81// harness is imported only when the browser half is going to run: a check on
82// the clock must not need a browser installed to say what the clock does.
83const CLOCK_ONLY = process.argv.includes('--clock');
84const { open, connectMock, signInAs, scratch, shot, mockLog } =
85 CLOCK_ONLY ? {} : await import('./harness.mjs');
86
87const OUT = path.join(os.homedir(), '.cache/daimond/triggers-shots');
88fs.mkdirSync(OUT, { recursive: true });
89
90const BI = process.argv.indexOf('--break');
91const BEQ = process.argv.find(a => a.startsWith('--break='));
92const BREAK = BEQ ? BEQ.split('=')[1] : (BI >= 0 ? (process.argv[BI + 1] || '') : '');
93
94let failures = 0;
95const check = (cond, msg, detail) => {
96 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
97 if (!cond) failures++;
98};
99
100const MODEL = 'accounts/fireworks/models/glm-5p2';
101
102// ══ 0. The activity clock ═════════════════════════════════════════════
103//
104// `www/js/triggers.js` exports for node, so the clock and the decision can be
105// driven at whatever speed the check needs. That matters more than tidiness
106// here: the fault this section was written for was a 30-minute timer that could
107// not fire, and no browser check is going to sit through thirty minutes of work
108// to see it.
109//
110// The loop below is the tick's RULE and nothing else about it -- fire what is
111// due, restart only the clocks that fired -- so what is being measured is the
112// module, not a second copy of the app.
113const require = createRequire(import.meta.url);
114// `allowed` asks the pause tree at the moment of firing, and the tree lives on
115// `window`. Node has none, so it gets one holding whatever `held` says -- which
116// is how a check below can pause a timer and watch what its clock does.
117const held = {};
118globalThis.window = { DaimondPause: { isPaused: (nodeId) => !!held[nodeId] } };
119const T = require('../www/js/triggers.js');
120
121if (BREAK === 'sharedclock') {
122 // The clock as it was: ONE stopwatch for the whole account, reset by whatever
123 // fires. This is the defect -- a 5-minute timer zeroes the count a 30-minute
124 // one is waiting on, every five minutes, so the longer one never arrives.
125 let shared = 0, saw = false;
126 T.noteActivity = () => { saw = true; };
127 T.tickActivity = (ms) => { if (saw) shared += ms; saw = false; return shared; };
128 T.activityMinutes = () => shared / 60000;
129 T.resetActivity = () => { shared = 0; };
130}
131if (BREAK === 'wallclock') {
132 // The input gate taken off, so every minute counts whether anybody was there
133 // or not. A tab left open overnight then greets its owner with eight turns
134 // nobody asked for, which is the failure this whole section is written
135 // against.
136 const tick = T.tickActivity;
137 T.tickActivity = (ms) => { T.noteActivity(); return tick(ms); };
138}
139if (BREAK === 'deaf') {
140 // The page's signs of life never reaching the clock -- one unwired listener,
141 // and nothing ever accrues or fires again.
142 T.noteActivity = () => {};
143}
144if (BREAK === 'neverreset') {
145 // A clock that is never restarted: the opposite mistake to zeroing it on a
146 // refusal, and it shows as a timer that fires every minute once it first
147 // comes due, instead of once a period.
148 T.resetActivity = () => {};
149}
150if (BREAK === 'openhanded') {
151 // An occasion with no reading read as "everything has accrued" rather than
152 // "nothing has". Failing open here means a caller that forgets to pass the
153 // clock fires every armed TA it can reach.
154 const real = T.due;
155 T.due = (id, actions, occ) => real(id, actions,
156 Object.assign({ minutesFor: () => Infinity }, occ));
157}
158if (BREAK === 'unheld') {
159 // The tree answering that nothing is ever held. A hold is enforcement, not
160 // decoration: a Diamond seeded paused, or a pause that arrived from another
161 // device between the schedule and the fire, then spends anyway.
162 globalThis.window.DaimondPause.isPaused = () => false;
163}
164if (BREAK === 'readsclock') {
165 // `due` consulting the clock itself instead of the occasion it was handed.
166 // It is then no longer pure, and cannot be asked a question about a reading
167 // the caller has -- which is how the per-TA clock reaches it.
168 const real = T.due;
169 T.due = (id, actions, occ) => real(id, actions, Object.assign({}, occ, {
170 minutesFor: (d, ta) => T.activityMinutes(d, ta.id),
171 }));
172}
173
174/// One armed timer of `n` minutes, whose action id is `name` -- the name is
175/// what a firing is reported under, so a check can say which one went.
176const timer = (n, name) => {
177 const ta = T.blank('activity');
178 ta.id = name;
179 ta.minutes = n;
180 ta.instruction = 'do the ' + name + ' thing';
181 return ta;
182};
183
184/// Run `n` minutes of the clock and report every firing, as `{ minute, id,
185/// actionId }`.
186///
187/// `arm` is `{ diamondId: [actions] }`. `idle` is a tab nobody touches. `refuse`
188/// is the dispatcher declining -- a turn already running, or a Diamond that is
189/// not the one on screen -- which is silent to `due` and is exactly the case
190/// where the accrued time must survive. One firing per Diamond per minute, as
191/// `Triggers.fire` allows.
192const run = (n, arm, { idle = false, refuse = null, from = 0 } = {}) => {
193 const log = [];
194 for (let m = from; m < from + n; m++) {
195 if (!idle) T.noteActivity();
196 T.tickActivity(60000);
197 for (const id of Object.keys(arm)) {
198 const owed = T.due(id, arm[id], {
199 kind: 'activity',
200 minutesFor: (d, ta) => T.activityMinutes(d, ta.id),
201 });
202 const t = owed[0];
203 if (!t) continue;
204 if (refuse && refuse(id, t, m + 1)) continue;
205 log.push({ minute: m + 1, id: id, actionId: t.id });
206 T.resetActivity(id, t.id);
207 }
208 }
209 return log;
210};
211
212{
213 // ── Two timers of different lengths, armed together ──
214 const SHORT = 5, LONG = 30;
215 const arm = {
216 quick: [timer(SHORT, 'five')],
217 slow: [timer(LONG, 'thirty')],
218 };
219 const log = run(LONG + 2, arm);
220 const at = (name) => log.filter(x => x.actionId === name).map(x => x.minute);
221 const said = 'five at ' + (at('five').join(',') || 'never')
222 + '; thirty at ' + (at('thirty').join(',') || 'never');
223 // THE ONE THIS SECTION EXISTS FOR. With one stopwatch for the account the
224 // 30-minute timer is not late, it is impossible: the 5-minute one zeroes the
225 // count every five minutes for ever, while the light says armed.
226 check(at('thirty').length > 0,
227 'a 30-minute timer fires even with a 5-minute one running beside it', said);
228 check(at('five').length > 0,
229 'and the 5-minute one is not starved in its turn', said);
230 // Each waits its OWN period rather than the other's: no firing before the
231 // minute it is set for, and no two firings closer together than that.
232 const spaced = (mins, period) =>
233 mins.every((v, i) => v - (i ? mins[i - 1] : 0) >= period);
234 check(spaced(at('thirty'), LONG),
235 'the 30-minute one waits thirty minutes of work, not somebody else’s five', said);
236 check(spaced(at('five'), SHORT),
237 'and the 5-minute one keeps its own period instead of firing every tick', said);
238}
239
240{
241 // ── Two timers on ONE Diamond take turns ──
242 //
243 // `Triggers.fire` sends at most one turn per Diamond however many matched:
244 // two instructions arriving as two turns is two bills and a daimon answering
245 // itself. The one that waits is not reset, so it comes to the next minute
246 // further ahead than the one that went -- the earlier line in the file wins
247 // the tick, not the session.
248 const arm = { both: [timer(5, 'first-line'), timer(5, 'second-line')] };
249 const log = run(30, arm);
250 const at = (name) => log.filter(x => x.actionId === name).map(x => x.minute);
251 check(at('second-line').length > 0,
252 'a second timer on the same Diamond is deferred a tick, not starved for ever',
253 'first at ' + (at('first-line').join(',') || 'never')
254 + '; second at ' + (at('second-line').join(',') || 'never'));
255}
256
257{
258 // ── A refused firing keeps what it accrued ──
259 //
260 // One Diamond dispatches all the way through; the other is refused until the
261 // minute it is not. The refused one must go at once on the first minute it is
262 // allowed to, because the time it accrued while being refused is still there.
263 const LIFT = 12, PERIOD = 5;
264 const arm = {
265 onscreen: [timer(PERIOD, 'on')],
266 offscreen: [timer(PERIOD, 'off')],
267 };
268 const log = run(LIFT + 4, arm, {
269 refuse: (id, t, minute) => id === 'offscreen' && minute <= LIFT,
270 });
271 const off = log.filter(x => x.actionId === 'off').map(x => x.minute);
272 const said = 'refused through minute ' + LIFT + '; fired at ' + (off.join(',') || 'never');
273 check(off.length > 0, 'a timer that was refused still fires once it can', said);
274 check(off.length > 0 && Math.min(...off) === LIFT + 1,
275 'and it goes on the FIRST minute it is allowed to — the refusal deferred its '
276 + 'time, it did not spend it', said);
277}
278
279{
280 // ── A tab nobody touches ──
281 //
282 // The deliberate semantic, and the reason none of this runs on a wall clock:
283 // "N minutes of USER ACTIVITY". An overnight tab must arrive at nothing.
284 const arm = { idle: [timer(5, 'sleeper')] };
285 const log = run(90, arm, { idle: true });
286 check(log.length === 0,
287 'a tab nobody touches accrues nothing, however long it is left open',
288 log.map(x => x.minute).join(',') || 'no firings');
289 // And it is still armed: the same TA fires once somebody works at it.
290 const after = run(6, arm, { from: 90 });
291 check(after.length > 0,
292 'and the same timer fires as soon as somebody actually works',
293 after.map(x => x.minute).join(',') || 'never');
294}
295
296{
297 // ── A timer added later starts from now ──
298 //
299 // Asking a TA its age is what starts its clock, so one made after a long
300 // session does not inherit that session and fire on the tick it was made.
301 const arm = { later: [timer(5, 'early')] };
302 run(40, arm);
303 const fresh = timer(10, 'added-late');
304 arm.later.push(fresh);
305 const mins = run(12, arm, { from: 40 }).filter(x => x.actionId === 'added-late')
306 .map(x => x.minute);
307 check(mins.length > 0, 'a timer added later does eventually fire',
308 mins.join(',') || 'never');
309 check(mins.length > 0 && Math.min(...mins) >= 40 + fresh.minutes,
310 'and it counts from when it was made, not from the work that went before it',
311 'added at 40, set for ' + fresh.minutes + ', fired at ' + (mins.join(',') || 'never'));
312}
313
314{
315 // ── A held timer has no clock until it is released ──
316 //
317 // The tree is the authority and it is asked at the moment of firing, so a
318 // held TA is dropped before anything asks its age -- and nothing starts
319 // counting. That is what a Diamond seeded paused wants: the Optimiser's
320 // thirty minutes are thirty minutes of work after its owner lets it go, not
321 // thirty minutes that quietly went by while it was held.
322 const ta = timer(5, 'onhold');
323 const arm = { held: [ta] };
324 const leaf = T.node('held', 'onhold');
325 held[leaf] = true;
326 const during = run(20, arm);
327 check(during.length === 0, 'a held timer does not fire, however long the work goes on',
328 during.map(x => x.minute).join(',') || 'no firings');
329 delete held[leaf];
330 const after = run(20, arm, { from: 20 }).map(x => x.minute);
331 check(after.length > 0 && Math.min(...after) >= 20 + ta.minutes,
332 'and once released it counts from the release, rather than firing on the '
333 + 'minutes it was held through',
334 'released at 20, set for ' + ta.minutes + ', fired at ' + (after.join(',') || 'never'));
335}
336
337{
338 // ── `due` is pure ──
339 //
340 // It answers about the reading it was HANDED. That is what lets one clock per
341 // TA reach it without this module holding any of them, and what makes every
342 // check above possible at all.
343 const ta = timer(30, 'p');
344 const asIf = (mins) => T.due('pure', [ta], {
345 kind: 'activity', minutesFor: () => mins,
346 }).length > 0;
347 check(asIf(31) && !asIf(29),
348 '`due` answers about the reading it is handed, not one it goes and finds',
349 '31 → ' + asIf(31) + ', 29 → ' + asIf(29));
350 check(!T.due('pure', [ta], { kind: 'activity' }).length,
351 'and an occasion carrying no reading is due nothing — a caller that forgets '
352 + 'the clock spends nothing, rather than everything');
353}
354
355if (CLOCK_ONLY) {
356 // The clock alone, for the breaks above and for anywhere without a browser.
357 console.log(failures === 0
358 ? `\nverify_triggers (clock): all checks pass.`
359 : `\nverify_triggers (clock): ${failures} failed.`);
360 process.exit(failures === 0 ? 0 : 1);
361}
362
363// Opened WITHOUT signing in, so a break can be installed before the app boots.
364// The defaults are seeded on the first render after sign-in, which is the thing
365// under test — patch it afterwards and the break patches nothing.
366const s = await open({ name: 'triggers', signIn: false, connect: false,
367 profile: scratch('pw', 'triggers-' + process.pid) });
368const { page: p } = s;
369try {
370 if (BREAK) console.log(` .. running with --break ${BREAK}`);
371 if (BREAK === 'unpaused') {
372 // The defaults seeded without the hold — which is what "start paused"
373 // costs if it is left to a branch instead of written at the leaf. A
374 // Diamond that arrives running spends on a schedule nobody set.
375 await p.evaluate(() => { DaimondPause.seedPaused = function () { /* BROKEN */ }; });
376 }
377 await signInAs(s, 'triggers');
378 await connectMock(s, { model: MODEL });
379 // THE TWO DEFAULT DIAMONDS ARE WITHHELD from this release -- see
380 // `seedDefaultDiamonds`, which is not called: creating a Diamond makes
381 // `diamonds/` exist, and `migrate_root` then refuses for ever to move a legacy
382 // root that arrives afterwards. So this file makes the same two itself, seeds
383 // them exactly as that function would, and checks the PROPERTIES notes2 asks
384 // for -- paused, with an inactive 30-minute timer -- without depending on the
385 // boot path that is held back.
386 await p.evaluate(() => DaimondDiamond.seedDefaults());
387 await p.waitForFunction(() =>
388 [...document.querySelectorAll('#diamond-list .session-box-name')]
389 .some(n => /Daimond Help/.test(n.textContent)), null, { timeout: 20000 }).catch(() => {});
390
391 // ══ 1. Two Diamonds, both paused ══════════════════════════════════
392 const rail = await p.evaluate(() =>
393 [...document.querySelectorAll('#diamond-list .diamond-box')].map(b => ({
394 name: ((b.querySelector('.session-box-name') || {}).textContent || '').trim(),
395 // Whether there IS a widget, apart from what it says. An absent one
396 // and a present one with no state both read `''`, and the difference
397 // between them is now the point.
398 has: !!b.querySelector('.pptw'),
399 state: (b.querySelector('.pptw') || {}).dataset ? b.querySelector('.pptw').dataset.state : '',
400 // Where it sits: the user's rule is that a light goes to the RIGHT of
401 // the name, hard against the cog, so a rail of mixed Diamonds keeps
402 // one left edge for its labels.
403 order: [...(b.querySelector('.session-box-header') || { children: [] }).children]
404 .map((el) => el.className.split(' ')[0]).join(','),
405 id: b.dataset.id,
406 })));
407 const help = rail.find(r => /Daimond Help/.test(r.name));
408 const opt = rail.find(r => /Daimond Optimiser/.test(r.name));
409 check(!!help, 'Daimond Help is there', rail.map(r => r.name).join(', ') || '(none)');
410 check(!!opt, 'Daimond Optimiser is there', rail.map(r => r.name).join(', ') || '(none)');
411 // Help has NO triggered actions, so it has nothing that spends without being
412 // asked — and therefore no widget and no hold. This read "Help starts
413 // PAUSED", which was right while every Diamond carried a `prompted` action;
414 // with that gone, a red light on Help would be a control standing for a
415 // decision the user makes by typing.
416 check(!!(help && help.has === false),
417 'Help carries no traffic light — with no actions, prompting it IS the choice to run it',
418 help && JSON.stringify(help.order));
419 check(!!(opt && opt.has === true && opt.state === 'pause'),
420 'the Optimiser does, and starts PAUSED — it has a timer that would spend unasked',
421 opt && opt.state);
422 // The user's placement rule, measured rather than eyeballed: name first, then
423 // the light, then the cog. It used to be light, name, cog — so a rail of
424 // Diamonds with and without one had two different left edges for its labels.
425 check(!!(opt && /^session-box-name,pptw,tile-cog/.test(opt.order)),
426 'and it sits to the RIGHT of the name, hard against the cog',
427 opt && opt.order);
428
429 // ══ 7. And a Diamond that IS held says where its play control is ══
430 if (opt) {
431 await p.evaluate((id) => {
432 document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`).click();
433 }, opt.id);
434 await p.waitForTimeout(900);
435 const ph = await p.evaluate(() =>
436 (document.getElementById('chat-input') || {}).placeholder || '');
437 check(!/paus/i.test(ph),
438 'a held Diamond’s input does not claim the box will not work', ph);
439 // THE PAIR. Asserting the wording alone would pass on any rewording; what
440 // makes it a property is that the box tells the truth about itself.
441 //
442 // MEASURED AT THE WIRE, not in the thread. Clicking the tile opens the
443 // CRYSTAL face, which does not render the conversation — so reading
444 // `.chat-msg-assistant` there finds nothing whether the turn ran or not,
445 // and the first version of this check went red against a turn that had
446 // reached the provider perfectly well. The request is the property; where
447 // the answer is drawn is a fact about which face is up.
448 const before = mockLog().length;
449 await p.fill('#chat-input', '@text held and answered');
450 await p.click('#chat-send');
451 await p.waitForTimeout(5000);
452 const sent = mockLog().length - before;
453 check(sent > 0,
454 'and the turn typed into it reaches the provider, which is why the old sentence was wrong',
455 `${sent} request(s) while held`);
456 }
457
458 // A Diamond whose TILE has no light must still be typeable when nothing has
459 // paused it, and must still have a release valve when something has. It is
460 // left in the pause tree deliberately — the global control writes through
461 // that tree, so dropping it out would mean "pause Everything" no longer
462 // stopped it spending.
463 if (help) {
464 await p.evaluate((id) => {
465 document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`).click();
466 }, help.id);
467 await p.waitForTimeout(900);
468 const ph = await p.evaluate(() =>
469 (document.getElementById('chat-input') || {}).placeholder || '');
470 check(!/paus/i.test(ph), 'a Diamond with no light on its tile is not held', ph);
471
472 const reach = await p.evaluate((id) => ({
473 // In the tree, so the global control still reaches it.
474 inTree: !!DaimondPause._core.findNode(DaimondCore.pauseTree(),
475 DaimondPause.id('root', 'diamonds', id) + '/self'),
476 }), help.id);
477 check(reach.inTree,
478 'but it IS in the pause tree, so “pause Everything” still stops it spending',
479 JSON.stringify(reach));
480 }
481
482 // ══ 2–4. The Optimiser's timer, and the pause tree ════════════════
483 if (opt) {
484 await p.evaluate((id) => {
485 document.querySelector(`#diamond-list .diamond-box[data-id="${id}"] .tile-cog`).click();
486 }, opt.id);
487 // The actions are named by the chooser's options now, not by a row each:
488 // notes2 asks for them to be "selected for editing from a pulldown", so
489 // the pulldown is the census of what a Diamond has.
490 await p.waitForSelector('.tile-dlg-card .trig-choose', { timeout: 8000 });
491 const rows = await p.evaluate(() => {
492 const sel = document.querySelector('.tile-dlg-card .trig-choose');
493 const light = document.querySelector('.tile-dlg-card .trig-pick .pptw');
494 return {
495 names: [...sel.options].map(o => o.textContent.trim()),
496 chosen: sel.value,
497 light: light ? light.dataset.state : '(none)',
498 };
499 });
500 check(rows.names.length === 1, 'the Optimiser has ONE action: the timer',
501 rows.names.join(' | '));
502 check(rows.names.some(n => /30 minutes/.test(n)),
503 'the timer is set for 30 minutes, as notes2 asks', rows.names.join(' | '));
504 check(rows.light === 'pause',
505 'and it starts INACTIVE, on the tree as well as in the record', rows.light);
506 // EVERY action has a light. The one that did not — `prompted`, drawn with
507 // a spacer where the widget goes — is gone, because prompting a Diamond
508 // is the user asking rather than an arrangement to be armed. The light now
509 // sits beside the pulldown and belongs to whichever action is chosen, and
510 // the option text carries the state of the ones that are not.
511 check(rows.light !== '(none)',
512 'and the chosen action has a light — there is no longer one that is only decoration',
513 rows.light);
514 check(rows.names.every(n => /^[▶⏸]/.test(n)),
515 'and every option says whether that action is running, so the pulldown hides no state',
516 rows.names.join(' | '));
517
518 await p.evaluate(() => {
519 const done = document.querySelector('.tile-dlg-done');
520 if (done) done.click();
521 });
522 await p.waitForTimeout(300);
523
524 // ══ 5. They are files, where you can read them ════════════════
525 const file = await p.evaluate(async (id) => {
526 const W = await import('/pkg/oxedyne_daimond.js');
527 try { return await W.store_read('diamonds/' + id + '/triggers.json'); }
528 catch (e) { return ''; }
529 }, opt.id);
530 check(!!file, 'the actions are a file at diamonds/<id>/triggers.json',
531 file ? file.replace(/\s+/g, ' ').slice(0, 70) : '(absent)');
532 let parsed = null;
533 try { parsed = JSON.parse(file); } catch (e) { parsed = null; }
534 check(!!parsed, 'and it parses');
535 const ta = parsed && (parsed.actions || []).find(x => x.kind === 'activity');
536 check(!!ta, 'with the timer in it', ta ? JSON.stringify(ta).slice(0, 80) : 'none');
537 check(!!(ta && ta.on === false),
538 'recorded as inactive, so the engine reads what the light shows',
539 ta && String(ta.on));
540 // NOT under `.daimond/`, which both trees hide: "an intuitive system
541 // directory hierarchy" means one you can see.
542 const listed = await p.evaluate(async (id) => {
543 const W = await import('/pkg/oxedyne_daimond.js');
544 try { return await W.store_list('diamonds/' + id); } catch (e) { return ''; }
545 }, opt.id);
546 check(/triggers\.json/.test(listed),
547 'and it is listed in the open, not hidden under a dotfile',
548 listed.replace(/\n/g, ' ').slice(0, 80));
549 }
550
551 // ══ 6. Context is sent once, and again when it changes ════════════
552 const ctx = await p.evaluate((brk) => {
553 const T = window.DaimondTriggers;
554 const ta = T.blank('activity');
555 ta.instruction = 'DO THE THING';
556 ta.context = 'BACKGROUND';
557 const first = T.compose(ta);
558 // The caller records the stamp only when the turn was accepted, which is
559 // what this simulates.
560 if (brk !== 'ctxtwice') ta.contextSent = first.sentContext;
561 const second = T.compose(ta);
562 ta.context = 'DIFFERENT BACKGROUND';
563 const third = T.compose(ta);
564 return { first: first.text, second: second.text, third: third.text };
565 }, BREAK);
566 check(/BACKGROUND/.test(ctx.first) && /DO THE THING/.test(ctx.first),
567 'the first firing carries the context in front of the instruction',
568 ctx.first.replace(/\n+/g, ' / '));
569 check(!/BACKGROUND/.test(ctx.second) && /DO THE THING/.test(ctx.second),
570 'the second does NOT — context is sent once',
571 ctx.second.replace(/\n+/g, ' / '));
572 check(/DIFFERENT BACKGROUND/.test(ctx.third),
573 'and changing the context makes it new again',
574 ctx.third.replace(/\n+/g, ' / '));
575
576 // ══ 8. A refused firing keeps the time it accrued ═════════════════
577 //
578 // The three refusals are silent: no model, a turn already running, and a
579 // Diamond that is not the one on screen. The last is the one a test can stage
580 // honestly, so a timer is armed on Help while the Optimiser is on screen. It
581 // comes due, it is refused -- deliberately, because moving the centre out from
582 // under somebody mid-sentence is worse than a turn that waits -- and the
583 // question is what happens to the six minutes it spent getting there.
584 //
585 // Driven through the real tick, because the fault was in the tick: it worked
586 // out what to zero by asking `due` a second time, and `due` is pure and can
587 // see neither a busy crystal nor which Diamond is on screen. It reported the
588 // refused timer as owed, the clock was zeroed anyway, and the accrued time
589 // went nowhere.
590 if (help && opt) {
591 if (BREAK === 'eagerreset') {
592 // The tick as it was: whatever the clock says is owed gets zeroed,
593 // whether or not a turn went anywhere.
594 await p.evaluate(() => {
595 const T = window.DaimondTriggers, tick = window.DaimondTriggerTick;
596 window.DaimondTriggerTick = async function () {
597 await tick();
598 document.querySelectorAll('#diamond-list .diamond-box').forEach((b) => {
599 (window.DaimondTriggersOf(b.dataset.id) || []).forEach((ta) => {
600 if (ta.kind !== 'activity') return;
601 if (T.activityMinutes(b.dataset.id, ta.id) >= (ta.minutes || 30)) {
602 T.resetActivity(b.dataset.id, ta.id);
603 }
604 });
605 });
606 };
607 });
608 }
609 const SAYS = 'TRIGGER DEFERRAL CHECK';
610 const PERIOD = 3;
611 // Where the mock's log has got to, so "was this sent?" is asked of THIS
612 // run. The log is a world's, not a run's, and a second run against the same
613 // world would otherwise find the first run's turn and call the refusal a
614 // dispatch.
615 const seen = mockLog().length;
616 const armed = await p.evaluate(async (a) => {
617 const T = window.DaimondTriggers;
618 const ta = T.blank('activity');
619 ta.id = 'defer-' + Date.now().toString(36);
620 ta.minutes = a.period;
621 ta.instruction = a.says;
622 await DaimondCore.triggerSet(a.id, ta);
623 // Running, said out loud rather than assumed: an unheld leaf reads as
624 // playing, and this check is about the refusal, not about what the tree
625 // happened to be seeded with.
626 DaimondPause.set(T.node(a.id, ta.id), true);
627 return ta.id;
628 }, { id: help.id, says: SAYS, period: PERIOD });
629
630 // The OPTIMISER on screen, so Help is not the Diamond a trigger may steer.
631 await p.evaluate((id) => {
632 document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`).click();
633 }, opt.id);
634 await p.waitForTimeout(700);
635 const held = await p.evaluate(async (a) => {
636 const T = window.DaimondTriggers;
637 for (let m = 0; m < a.mins; m++) {
638 T.noteActivity();
639 await window.DaimondTriggerTick();
640 }
641 // Asking does not consume it; only a firing does.
642 return T.activityMinutes(a.id, a.ta);
643 }, { id: help.id, ta: armed, mins: PERIOD * 2 });
644 // JSON rather than `content`: a message's content is a string in the simple
645 // case and an array of parts in every other, and the question here is only
646 // whether the instruction reached the wire at all.
647 const spoke = () => mockLog().slice(seen).some(r => JSON.stringify(r).includes(SAYS));
648 check(!spoke(),
649 'a trigger does not steer a Diamond the user is not looking at',
650 'the model was ' + (spoke() ? 'sent it anyway' : 'not sent the instruction'));
651 check(held >= PERIOD,
652 'and the minutes it accrued while being refused are still there, not spent on nothing',
653 held.toFixed(1) + ' minutes held, set for ' + PERIOD);
654
655 // And on screen, the same accrued time arrives at a turn on the very next
656 // tick — it did not go back to zero and start the wait again.
657 await p.evaluate((id) => {
658 document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`).click();
659 }, help.id);
660 await p.waitForTimeout(700);
661 await p.evaluate(() => {
662 window.DaimondTriggers.noteActivity();
663 window.DaimondTriggerTick();
664 });
665 const restarted = await p.waitForFunction(
666 (a) => window.DaimondTriggers.activityMinutes(a.id, a.ta) < 1,
667 { id: help.id, ta: armed }, { timeout: 40000 }).then(() => true).catch(() => false);
668 await p.waitForTimeout(600);
669 check(spoke(), 'once the Diamond is on screen the held time reaches the daimon',
670 'the model was ' + (spoke() ? 'sent the instruction' : 'never sent it'));
671 check(restarted,
672 'and only then does that timer start counting again — a firing consumes it, '
673 + 'a refusal never did');
674 }
675
676 // ══ The Pending panel ═════════════════════════════════════════════
677 await p.evaluate(() => DaimondPanels.show('pending'));
678 await p.waitForTimeout(500);
679 const empty = await p.evaluate(() =>
680 (document.getElementById('pending-list') || {}).textContent || '');
681 check(/nothing waiting/i.test(empty), 'Pending says so when nothing is waiting', empty.trim());
682
683 const ids = await p.evaluate(() => [
684 DaimondPendingView.add({ headline: 'Send the quote to Ada', detail: 'It is drafted.',
685 priority: 'high', diamondName: 'Work' }),
686 DaimondPendingView.add({ headline: 'Tidy the notes', detail: '', priority: 'low',
687 diamondName: 'Work' }),
688 ]);
689 check(ids.every(Boolean), 'two items can be raised', String(ids.length));
690 await p.waitForTimeout(400);
691 const order = await p.evaluate(() =>
692 [...document.querySelectorAll('#pending-list .pend-line')].map(n => n.textContent.trim()));
693 check(order.length === 2, 'both are on the panel', order.join(' | '));
694 check(order[0] === 'Send the quote to Ada',
695 'and the high-priority one is first, because the question is “what next”',
696 order.join(' | '));
697 const badge = await p.evaluate(() =>
698 (document.getElementById('pending-count') || {}).textContent || '');
699 check(badge === '2', 'the count says how many are waiting', badge);
700
701 // The headline expands, because a panel of one-liners cannot be judged.
702 await p.evaluate(() => document.querySelector('#pending-list .pend-line').click());
703 await p.waitForTimeout(200);
704 const expanded = await p.evaluate(() => {
705 const d = document.querySelector('#pending-list .pend-detail');
706 return { shown: d && d.style.display !== 'none', text: d ? d.textContent : '' };
707 });
708 check(!!(expanded.shown && /drafted/.test(expanded.text)),
709 'the headline expands to what the daimon actually said', expanded.text.slice(0, 50));
710
711 // Drop takes it away, and the panel is one shorter.
712 await p.evaluate(() => document.querySelector('#pending-list .pend-no').click());
713 await p.waitForTimeout(400);
714 const left = await p.evaluate(() =>
715 [...document.querySelectorAll('#pending-list .pend-line')].map(n => n.textContent.trim()));
716 check(left.length === 1 && left[0] === 'Tidy the notes',
717 'dropping one takes it off the list', left.join(' | '));
718 await shot(s, 'pending');
719
720} catch (e) {
721 check(false, 'the run finished', String(e && e.message || e));
722 try { await shot(s, 'threw'); } catch {}
723} finally {
724 await s.close();
725}
726
727console.log(failures === 0
728 ? `\nverify_triggers: all checks pass.`
729 : `\nverify_triggers: ${failures} failed.`);
730process.exit(failures === 0 ? 0 : 1);