oxedyne/daimond/dev/verify_tapulldown.mjs
15.9 KiB, 1 run
created by r2519314175:727, 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_tapulldown.mjs -- triggered actions are chosen from a pulldown. |
| 2 | // |
| 3 | // notes2, verbatim: "new triggered actions (TAs) can be added with a + icon, |
| 4 | // and selected for editing from a pulldown", and "To avoid clutter, Instruction |
| 5 | // and Context should just show an edit button and a copy button to facilitate |
| 6 | // copying between TAs and diamonds." |
| 7 | // |
| 8 | // A previous session recorded this as decided-against on the grounds that most |
| 9 | // Diamonds have one action and hiding a list of one behind a click is worse |
| 10 | // than showing it. That was overruled: it is the spec. These are the properties |
| 11 | // the spec is asking for. |
| 12 | // |
| 13 | // 1. Every action a Diamond has appears in the pulldown, and only there -- |
| 14 | // there is no second list of rows saying the same thing twice. |
| 15 | // 2. Pressing + adds an action AND chooses it. Add-then-hunt is not |
| 16 | // add-then-choose, and an action you cannot find is worse than no action. |
| 17 | // 3. Choosing an option shows THAT action's settings: a mail action's mailbox |
| 18 | // and folder, a timer's minutes. This is the check that a pulldown which |
| 19 | // changes the label but not the panel would fail. |
| 20 | // 4. Instruction and Context are an edit button and a copy button, not two |
| 21 | // textareas. The clutter rule is the reason the pulldown exists at all, so |
| 22 | // a panel that inlines them defeats the change. |
| 23 | // 5. What is written reaches triggers.json, so the file the daimon reads and |
| 24 | // the box the user typed in are the same thing. |
| 25 | // 6. Removing the chosen action leaves a real choice behind rather than an |
| 26 | // empty panel. |
| 27 | // |
| 28 | // Also: a new action must arrive HELD, and held means held on the PAUSE TREE. |
| 29 | // `DaimondTriggers.allowed` asks the tree and never reads the record's `on`, so |
| 30 | // an action that only wrote `on: false` was armed from birth -- unable to fire |
| 31 | // only for as long as it had nothing to say. Found by this file, because the |
| 32 | // pulldown puts the armed state in the option text where the row list never |
| 33 | // showed it. |
| 34 | // |
| 35 | // Proved red by hand rather than by a --break switch, because this tree's |
| 36 | // convention is to patch the thing under test from the test, and mountTriggers |
| 37 | // is a closure that cannot be reached. Each was shown failing by editing |
| 38 | // www/js/daimond.js and reverting: |
| 39 | // |
| 40 | // check 2 -- delete `chosen = ta.id;` from the + handler (4 red) |
| 41 | // check 3 -- drop `chosen = sel.value` from the change handler (2 red) |
| 42 | // check 4 -- render instruction as a textarea in triggerPanel (5 red) |
| 43 | // held -- drop the seedPaused call from the + handler (2 red) |
| 44 | // check 6 -- drop the `chosen` fallback at the top of draw() (1 red) |
| 45 | // |
| 46 | // Note what check 6's break is NOT: clearing `chosen` after a removal is not |
| 47 | // the mechanism and never was. draw() falls back whenever `chosen` names an |
| 48 | // action that is not there, which is the same guard that has to survive an |
| 49 | // action removed on another device -- so the explicit clear was dead code and |
| 50 | // has been removed rather than left looking load-bearing. |
| 51 | // |
| 52 | // Needs dev/serve.mjs and dev/mockllm.mjs (dev/world.sh N --up gives both). |
| 53 | import { open, connectMock, signInAs, scratch } from './harness.mjs'; |
| 54 | |
| 55 | let failures = 0; |
| 56 | const check = (cond, msg, detail) => { |
| 57 | if (!cond) failures++; |
| 58 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail ? ' -- ' + detail : '')); |
| 59 | }; |
| 60 | |
| 61 | const s = await open({ name: 'tapulldown', signIn: false, connect: false, |
| 62 | profile: scratch('pw', 'tapulldown-' + process.pid) }); |
| 63 | const { page: p } = s; |
| 64 | |
| 65 | /// What the trigger area is showing: the options, which is chosen, and what the |
| 66 | /// panel beneath it contains. Read from the DOM, because the question is what |
| 67 | /// the user is looking at rather than what the record says. |
| 68 | async function area() { |
| 69 | return p.evaluate(() => { |
| 70 | const sel = document.querySelector('.tile-dlg-card .trig-choose'); |
| 71 | const panel = document.querySelector('.tile-dlg-card .trig-panel'); |
| 72 | const labels = panel |
| 73 | ? [...panel.querySelectorAll('.trig-label')].map(l => l.textContent.trim()) |
| 74 | : []; |
| 75 | return { |
| 76 | options: sel ? [...sel.options].map(o => o.textContent.trim()) : [], |
| 77 | chosen: sel ? sel.value : '', |
| 78 | chosenLabel: sel && sel.selectedIndex >= 0 |
| 79 | ? sel.options[sel.selectedIndex].textContent.trim() : '', |
| 80 | panelLabels: labels, |
| 81 | // The clutter rule, measured: a long text inside the panel means the |
| 82 | // panel is a form again. |
| 83 | textareasInPanel: panel ? panel.querySelectorAll('textarea').length : -1, |
| 84 | // And the pair that replaced them. |
| 85 | textRows: panel ? panel.querySelectorAll('.trig-text-row').length : -1, |
| 86 | buttonsPerTextRow: panel |
| 87 | ? [...panel.querySelectorAll('.trig-text-row')] |
| 88 | .map(r => r.querySelectorAll('button').length) : [], |
| 89 | // The old shape. If rows come back, the dialog says everything twice. |
| 90 | legacyRows: document.querySelectorAll('.tile-dlg-card .trig-row').length, |
| 91 | }; |
| 92 | }); |
| 93 | } |
| 94 | |
| 95 | try { |
| 96 | await signInAs(s, 'tapulldown'); |
| 97 | await connectMock(s); |
| 98 | |
| 99 | // Daimond Help is the subject: seeded by the same call verify_triggers uses, |
| 100 | // and the one default that arrives with NO triggered actions -- so the empty |
| 101 | // case below is the real empty case rather than one this file arranged. |
| 102 | await p.evaluate(() => DaimondDiamond.seedDefaults()); |
| 103 | await p.waitForFunction(() => |
| 104 | [...document.querySelectorAll('#diamond-list .session-box-name')] |
| 105 | .some(n => /Daimond Help/.test(n.textContent)), null, { timeout: 20000 }); |
| 106 | const id = await p.evaluate(() => { |
| 107 | const box = [...document.querySelectorAll('#diamond-list .diamond-box')] |
| 108 | .find(b => /Daimond Help/.test(b.textContent)); |
| 109 | return box ? box.dataset.id : ''; |
| 110 | }); |
| 111 | check(!!id, 'a Diamond to hang actions on', String(id)); |
| 112 | |
| 113 | await p.evaluate((did) => { |
| 114 | document.querySelector(`#diamond-list .diamond-box[data-id="${did}"] .tile-cog`).click(); |
| 115 | }, id); |
| 116 | await p.waitForSelector('.tile-dlg-card .trig-add', { timeout: 8000 }); |
| 117 | |
| 118 | // ══ A fresh Diamond has no actions, and says so with ONE button ═══ |
| 119 | // |
| 120 | // notes3: "should simply show the plus icon button when no TAs are |
| 121 | // registered, and reveal the pulldown when clicked". Most Diamonds never have |
| 122 | // a triggered action, and for those the section was a pulldown, a kind |
| 123 | // chooser and a paragraph explaining that none of them had anything to do. |
| 124 | // So what is asserted is the whole of the section: one control, and nothing |
| 125 | // beside it. |
| 126 | { |
| 127 | const a = await area(); |
| 128 | check(a.options.length === 0, |
| 129 | 'a new Diamond starts with no actions, so there is nothing to choose', |
| 130 | a.options.join(' | ') || '(none)'); |
| 131 | const bare = await p.evaluate(() => { |
| 132 | const host = document.querySelector('.tile-dlg-card .trig-list'); |
| 133 | if (!host) return null; |
| 134 | const ctl = [...host.querySelectorAll('button, select, input, textarea')]; |
| 135 | return { |
| 136 | controls: ctl.map(c => (c.textContent || '').trim() || c.tagName.toLowerCase()), |
| 137 | kinds: host.querySelectorAll('select').length, |
| 138 | // The explanatory line about triggers.json: worth saying once there |
| 139 | // IS a file to look in, and noise before that. |
| 140 | noteShown: [...document.querySelectorAll('.tile-dlg-card .tile-dlg-note')] |
| 141 | .some(n => /triggers\.json/.test(n.textContent || '') |
| 142 | && n.style.display !== 'none'), |
| 143 | }; |
| 144 | }); |
| 145 | check(bare && bare.controls.length === 1 && bare.controls[0] === '+', |
| 146 | 'and the section is a + and nothing else', |
| 147 | bare && JSON.stringify(bare.controls)); |
| 148 | check(bare && bare.kinds === 0, |
| 149 | 'with no kind pulldown standing open over an empty section', |
| 150 | bare && String(bare.kinds)); |
| 151 | check(bare && !bare.noteShown, |
| 152 | 'and nothing explaining where actions are kept, because there is no file yet'); |
| 153 | } |
| 154 | |
| 155 | // ══ 2. + adds AND chooses ═════════════════════════════════════════ |
| 156 | // |
| 157 | // On an empty section the first press REVEALS the kind pulldown rather than |
| 158 | // creating anything -- what sets an action off is decided when it is made, |
| 159 | // and a + that quietly chose would put the mail case behind an action you |
| 160 | // then have to delete. So: press until the chooser is there, choose, press. |
| 161 | async function add(kind) { |
| 162 | await p.evaluate(() => { |
| 163 | if (document.querySelector('.tile-dlg-card .trig-add select')) return; |
| 164 | const plus = document.querySelector('.tile-dlg-card .trig-add button'); |
| 165 | if (plus) plus.click(); |
| 166 | }); |
| 167 | await p.waitForSelector('.tile-dlg-card .trig-add select', { timeout: 6000 }); |
| 168 | await p.evaluate((k) => { |
| 169 | const sel = document.querySelector('.tile-dlg-card .trig-add select'); |
| 170 | sel.value = k; |
| 171 | sel.dispatchEvent(new Event('change', { bubbles: true })); |
| 172 | document.querySelector('.tile-dlg-card .trig-add button').click(); |
| 173 | }, kind); |
| 174 | await p.waitForTimeout(400); |
| 175 | } |
| 176 | |
| 177 | // The reveal itself, asserted once: pressing + on an empty section must not |
| 178 | // register an action. A + that created one would make the check above pass |
| 179 | // and the user's first press irreversible. |
| 180 | { |
| 181 | await p.evaluate(() => { |
| 182 | const plus = document.querySelector('.tile-dlg-card .trig-add button'); |
| 183 | if (plus) plus.click(); |
| 184 | }); |
| 185 | await p.waitForTimeout(300); |
| 186 | const a = await area(); |
| 187 | const revealed = await p.evaluate(() => |
| 188 | !!document.querySelector('.tile-dlg-card .trig-add select')); |
| 189 | check(revealed, 'pressing + on an empty section reveals the kind pulldown'); |
| 190 | check(a.options.length === 0, |
| 191 | 'and registers nothing until a kind has been chosen -- the first press is not a commitment', |
| 192 | a.options.join(' | ') || '(none)'); |
| 193 | } |
| 194 | |
| 195 | await add('activity'); |
| 196 | { |
| 197 | const a = await area(); |
| 198 | check(a.options.length === 1, 'pressing + adds one action', a.options.join(' | ')); |
| 199 | check(/minutes/i.test(a.chosenLabel), |
| 200 | 'and the action it added is the one showing -- added is chosen', a.chosenLabel); |
| 201 | check(a.panelLabels.some(l => /minute/i.test(l)), |
| 202 | 'so the panel is the timer’s own settings', a.panelLabels.join(' | ')); |
| 203 | // A new action must arrive HELD, and held means held on the pause tree. |
| 204 | // `DaimondTriggers.allowed` asks the tree and never reads the record's |
| 205 | // `on`, so an action that only wrote `on: false` was armed -- unable to |
| 206 | // fire only for as long as it had no instruction to send. |
| 207 | const arrival = await p.evaluate((did) => { |
| 208 | const sel = document.querySelector('.tile-dlg-card .trig-choose'); |
| 209 | const t = DaimondTriggers; |
| 210 | const rec = (window.DaimondCore ? null : null); |
| 211 | return { |
| 212 | light: (document.querySelector('.tile-dlg-card .trig-pick .pptw') || {}).dataset.state, |
| 213 | // The question that matters: would it be let through if it had |
| 214 | // something to say? |
| 215 | allowedWithWords: t.allowed(did, { id: sel.value, kind: 'activity', minutes: 30, |
| 216 | instruction: 'SOMETHING TO SAY' }), |
| 217 | }; |
| 218 | }, id); |
| 219 | check(arrival.light === 'pause', 'a new action arrives held', arrival.light); |
| 220 | check(arrival.allowedWithWords === false, |
| 221 | 'and held on the PAUSE TREE, so writing an instruction does not arm it by itself', |
| 222 | String(arrival.allowedWithWords)); |
| 223 | } |
| 224 | |
| 225 | await add('mail'); |
| 226 | { |
| 227 | const a = await area(); |
| 228 | check(a.options.length === 2, 'a second + adds a second action', a.options.join(' | ')); |
| 229 | check(/mail/i.test(a.chosenLabel), 'and again the new one is chosen', a.chosenLabel); |
| 230 | // ══ 3. The panel follows the choice ═══════════════════════════ |
| 231 | check(a.panelLabels.some(l => /mailbox/i.test(l)) && a.panelLabels.some(l => /folder/i.test(l)), |
| 232 | 'the panel changed with it -- a mail action shows mailbox and folder', |
| 233 | a.panelLabels.join(' | ')); |
| 234 | check(!a.panelLabels.some(l => /minute/i.test(l)), |
| 235 | 'and no longer shows the timer’s minutes', a.panelLabels.join(' | ')); |
| 236 | } |
| 237 | |
| 238 | // ══ 1. One list, not two ══════════════════════════════════════════ |
| 239 | { |
| 240 | const a = await area(); |
| 241 | check(a.legacyRows === 0, |
| 242 | 'the actions are listed once, in the pulldown -- there is no row list saying it again', |
| 243 | String(a.legacyRows)); |
| 244 | } |
| 245 | |
| 246 | // ══ 4. Instruction and Context are edit + copy ════════════════════ |
| 247 | { |
| 248 | const a = await area(); |
| 249 | check(a.textRows === 2, |
| 250 | 'Instruction and Context are each one row', String(a.textRows)); |
| 251 | check(a.textareasInPanel === 0, |
| 252 | 'and neither is a textarea sitting in the panel -- that is the clutter the pulldown exists to avoid', |
| 253 | String(a.textareasInPanel)); |
| 254 | check(a.buttonsPerTextRow.length === 2 && a.buttonsPerTextRow.every(n => n === 2), |
| 255 | 'each shows exactly two buttons: edit, and copy', |
| 256 | a.buttonsPerTextRow.join(',')); |
| 257 | } |
| 258 | |
| 259 | // ══ Choosing the other one goes back ══════════════════════════════ |
| 260 | { |
| 261 | const first = await p.evaluate(() => { |
| 262 | const sel = document.querySelector('.tile-dlg-card .trig-choose'); |
| 263 | const other = [...sel.options].find(o => o.value !== sel.value); |
| 264 | sel.value = other.value; |
| 265 | sel.dispatchEvent(new Event('change', { bubbles: true })); |
| 266 | return other.value; |
| 267 | }); |
| 268 | await p.waitForTimeout(300); |
| 269 | const a = await area(); |
| 270 | check(a.chosen === first, 'choosing another action selects it', a.chosenLabel); |
| 271 | check(a.panelLabels.some(l => /minute/i.test(l)), |
| 272 | 'and brings its settings back', a.panelLabels.join(' | ')); |
| 273 | } |
| 274 | |
| 275 | // ══ 5. What is typed reaches the file ═════════════════════════════ |
| 276 | { |
| 277 | // Open the instruction editor, type, and close it the way the reader does. |
| 278 | await p.evaluate(() => { |
| 279 | const row = [...document.querySelectorAll('.tile-dlg-card .trig-text-row')][0]; |
| 280 | row.querySelectorAll('button')[0].click(); |
| 281 | }); |
| 282 | await p.waitForSelector('.tile-dlg-card textarea.trig-area', { timeout: 6000 }); |
| 283 | await p.evaluate(() => { |
| 284 | const area = document.querySelector('.tile-dlg-card textarea.trig-area'); |
| 285 | area.value = 'REACHES THE FILE'; |
| 286 | area.dispatchEvent(new Event('input', { bubbles: true })); |
| 287 | // The last .tile-dlg-done is the editor's; the settings dialog beneath |
| 288 | // has one too and closing that instead would prove nothing. |
| 289 | const dones = [...document.querySelectorAll('.tile-dlg-done')]; |
| 290 | dones[dones.length - 1].click(); |
| 291 | }); |
| 292 | await p.waitForTimeout(600); |
| 293 | |
| 294 | const a = await area(); |
| 295 | check(/REACHES THE FILE/.test(a.panelLabels.join(' ') + ' ' + (await p.evaluate(() => |
| 296 | [...document.querySelectorAll('.tile-dlg-card .trig-gist')].map(g => g.textContent).join(' ')))), |
| 297 | 'what was typed shows on the row without reopening the editor'); |
| 298 | |
| 299 | const file = await p.evaluate(async (did) => { |
| 300 | const W = await import('/pkg/oxedyne_daimond.js'); |
| 301 | try { return await W.store_read('diamonds/' + did + '/triggers.json'); } |
| 302 | catch (e) { return ''; } |
| 303 | }, id); |
| 304 | let parsed = null; |
| 305 | try { parsed = JSON.parse(file); } catch (e) { parsed = null; } |
| 306 | const written = parsed && (parsed.actions || []).some(x => x.instruction === 'REACHES THE FILE'); |
| 307 | check(!!written, |
| 308 | 'and it is in triggers.json, where the daimon reads it', |
| 309 | file ? file.replace(/\s+/g, ' ').slice(0, 90) : '(absent)'); |
| 310 | } |
| 311 | |
| 312 | // ══ 6. Removing the chosen one leaves a real choice ═══════════════ |
| 313 | { |
| 314 | const before = (await area()).options.length; |
| 315 | await p.evaluate(() => { |
| 316 | const btns = document.querySelectorAll('.tile-dlg-card .trig-pick button'); |
| 317 | btns[btns.length - 1].click(); |
| 318 | }); |
| 319 | await p.waitForTimeout(400); |
| 320 | // An action with an instruction asks first; answer it. |
| 321 | await p.evaluate(() => { |
| 322 | const ok = [...document.querySelectorAll('.dlg-ok, .modal-card button')] |
| 323 | .find(b => /remove/i.test(b.textContent)); |
| 324 | if (ok) ok.click(); |
| 325 | }); |
| 326 | await p.waitForTimeout(500); |
| 327 | const a = await area(); |
| 328 | check(a.options.length === before - 1, 'removing takes the action away', |
| 329 | a.options.join(' | ') || '(none)'); |
| 330 | check(a.options.length === 0 || (!!a.chosen && a.panelLabels.length > 0), |
| 331 | 'and what is left is chosen, not an empty panel over a full pulldown', |
| 332 | a.chosenLabel + ' / ' + a.panelLabels.join(' | ')); |
| 333 | } |
| 334 | } catch (e) { |
| 335 | failures++; |
| 336 | console.log(' FAIL threw -- ' + (e && e.message ? e.message.split('\n')[0] : e)); |
| 337 | } finally { |
| 338 | await s.close(); |
| 339 | } |
| 340 | |
| 341 | console.log(''); |
| 342 | console.log(failures ? `verify_tapulldown: ${failures} FAILED` : 'verify_tapulldown: all checks pass.'); |
| 343 | process.exit(failures ? 1 : 0); |