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. |
| 75 | import fs from 'node:fs'; |
| 76 | import path from 'node:path'; |
| 77 | import os from 'node:os'; |
| 78 | import { 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. |
| 83 | const CLOCK_ONLY = process.argv.includes('--clock'); |
| 84 | const { open, connectMock, signInAs, scratch, shot, mockLog } = |
| 85 | CLOCK_ONLY ? {} : await import('./harness.mjs'); |
| 86 | |
| 87 | const OUT = path.join(os.homedir(), '.cache/daimond/triggers-shots'); |
| 88 | fs.mkdirSync(OUT, { recursive: true }); |
| 89 | |
| 90 | const BI = process.argv.indexOf('--break'); |
| 91 | const BEQ = process.argv.find(a => a.startsWith('--break=')); |
| 92 | const BREAK = BEQ ? BEQ.split('=')[1] : (BI >= 0 ? (process.argv[BI + 1] || '') : ''); |
| 93 | |
| 94 | let failures = 0; |
| 95 | const check = (cond, msg, detail) => { |
| 96 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 97 | if (!cond) failures++; |
| 98 | }; |
| 99 | |
| 100 | const 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. |
| 113 | const 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. |
| 117 | const held = {}; |
| 118 | globalThis.window = { DaimondPause: { isPaused: (nodeId) => !!held[nodeId] } }; |
| 119 | const T = require('../www/js/triggers.js'); |
| 120 | |
| 121 | if (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 | } |
| 131 | if (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 | } |
| 139 | if (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 | } |
| 144 | if (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 | } |
| 150 | if (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 | } |
| 158 | if (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 | } |
| 164 | if (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. |
| 176 | const 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. |
| 192 | const 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 | |
| 355 | if (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. |
| 366 | const s = await open({ name: 'triggers', signIn: false, connect: false, |
| 367 | profile: scratch('pw', 'triggers-' + process.pid) }); |
| 368 | const { page: p } = s; |
| 369 | try { |
| 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 | |
| 727 | console.log(failures === 0 |
| 728 | ? `\nverify_triggers: all checks pass.` |
| 729 | : `\nverify_triggers: ${failures} failed.`); |
| 730 | process.exit(failures === 0 ? 0 : 1); |