Oregami
Repositories/oxedyne/daimond

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).
53import { open, connectMock, signInAs, scratch } from './harness.mjs';
54
55let failures = 0;
56const check = (cond, msg, detail) => {
57 if (!cond) failures++;
58 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail ? ' -- ' + detail : ''));
59};
60
61const s = await open({ name: 'tapulldown', signIn: false, connect: false,
62 profile: scratch('pw', 'tapulldown-' + process.pid) });
63const { 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.
68async 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
95try {
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
341console.log('');
342console.log(failures ? `verify_tapulldown: ${failures} FAILED` : 'verify_tapulldown: all checks pass.');
343process.exit(failures ? 1 : 0);