Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_social.mjs

32.7 KiB, 17 runs

created by r2519314175:683, 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_social.mjs — Phase 1 of the Social panel: the panel, the badge, the chip.
2//
3// Three things landed together and each has its own way of looking finished
4// while doing nothing:
5//
6// 1. THE PANEL, renamed from Improve (decision 13). A rename is four
7// hand-wired seams as well as the markup, and every one of them fails
8// QUIETLY: without its `MOBILE_GUESTS` key the panel is a BLANK SCREEN on a
9// phone; without its `onOpen` dispatch it opens and draws nothing; without
10// its `NO_ASK` key the sheet offers an ask pill over a box you write in;
11// and `telemetry.js` holds the panel as an ORDINAL, so a rename that MOVED
12// it changes the meaning of every number already gathered.
13// 2. THE DOCK COUNT BADGE. With Web Push declined this is the app's only
14// notification, and the two ways it can be wrong are drawing a zero and
15// counting what the reader is already looking at.
16// 3. THE REFERENCE CHIP, and the five rules of §4.3 — resolved by the reader,
17// never disclosing what the reader cannot see, never a URL, at most four,
18// and LAZY.
19//
20// THE TRAP THIS FILE IS WRITTEN AROUND. A verifier here once shipped green
21// while proving nothing, by asserting that `dataset.mpanel` had been SET rather
22// than that anything was on the screen. And an absent element reports itself to
23// a browser locator as HIDDEN, so a check that only asks "is it hidden" passes
24// over a button that does not exist. So, throughout:
25//
26// - anything asserted to be hidden is COUNTED first;
27// - anything asserted to be visible is measured with `getBoundingClientRect()`
28// and required to have real area inside the viewport — not an ancestry
29// check, and never `dataset.mpanel`.
30//
31// Needs `dev/serve.mjs` (DAIMOND_PORT, default 8777) and `dev/mockllm.mjs`.
32// `bash dev/world.sh N --up` prints a matching set.
33//
34// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
35// deliberately damaged copy of a source file and the run is expected to FAIL. A
36// break whose anchor is not there exactly once ABORTS rather than passing
37// quietly, which is how the last stale break in this tree was found.
38//
39// node dev/verify_social.mjs --break nochip # 1e a chip has gone
40// node dev/verify_social.mjs --break swapchip # 1f two chips have swapped
41// node dev/verify_social.mjs --break sixthchip # 1g a sixth arrived unannounced
42// node dev/verify_social.mjs --break noguest # 1a the panel is blank on a phone
43// node dev/verify_social.mjs --break noask # 1b the sheet offers an ask pill
44// node dev/verify_social.mjs --break moveord # 1c the telemetry ordinal moved
45// node dev/verify_social.mjs --break bootblind # 1d a panel open at boot is never told
46// node dev/verify_social.mjs --break zerobadge # 2a the badge draws a 0
47// node dev/verify_social.mjs --break badgeblind # 2b it counts what you are looking at
48// node dev/verify_social.mjs --break badgestuck # 2c looking at it does not clear it
49// node dev/verify_social.mjs --break fiveref # 3a a fifth reference is drawn
50// node dev/verify_social.mjs --break eagerref # 3b every chip resolves on sight
51// node dev/verify_social.mjs --break senderlabel # 3c the sender names the thing
52// node dev/verify_social.mjs --break refhref # 3d a reference is drawn as a link
53// node dev/verify_social.mjs --break msgname # 4 two panels called Message
54// node dev/verify_social.mjs # and then, clean
55import fs from 'node:fs';
56import path from 'node:path';
57import { fileURLToPath } from 'node:url';
58import { open, shot, scratch } from './harness.mjs';
59
60const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www');
61
62const BREAK = (() => {
63 const i = process.argv.indexOf('--break');
64 return i > 0 ? String(process.argv[i + 1] || '') : '';
65})();
66
67// ── The breaks ───────────────────────────────────────────────────────
68//
69// Each is a real edit to a real source file, served in place of it. `find` is a
70// literal; `re` is a SHAPE, used wherever the anchor sits inside something that
71// is edited often — a table of panels, say. The literal form there is what went
72// stale in `verify_mobile.mjs`: it pinned four lines of `MOBILE_GUESTS`, three
73// panels were added to the table afterwards, and the break silently stopped
74// applying at all.
75const BREAKS = {
76 noguest: [{
77 file: 'js/daimond.js',
78 re: /var MOBILE_GUESTS = \{[\s\S]*?\n\t\};/,
79 with: 'var MOBILE_GUESTS = { web: 1, doc: 1, msg: 1, compose: 1, tools: 1, '
80 + 'spend: 1, term: 1, trash: 1 };',
81 }],
82 // The three ways a head of chips goes wrong, and they are three because one
83 // assertion has to redden for all of them: a set membership test would pass
84 // `swapchip`, and a count would pass it too.
85 nochip: [{
86 file: 'index.html',
87 find: '\t\t\t\t\t<button type="button" class="imp-chip" data-view="people" aria-pressed="false" data-i18n="social.people">People</button>\n',
88 with: '',
89 }],
90 swapchip: [{
91 file: 'index.html',
92 find: '<button type="button" class="imp-chip" data-view="messages" aria-pressed="false" data-i18n="social.messages">Messages</button>\n'
93 + '\t\t\t\t\t<button type="button" class="imp-chip" data-view="people" aria-pressed="false" data-i18n="social.people">People</button>',
94 with: '<button type="button" class="imp-chip" data-view="people" aria-pressed="false" data-i18n="social.people">People</button>\n'
95 + '\t\t\t\t\t<button type="button" class="imp-chip" data-view="messages" aria-pressed="false" data-i18n="social.messages">Messages</button>',
96 }],
97 // Untranslated on purpose: a chip nobody decided on is a chip nobody put
98 // through the eight locale files either.
99 sixthchip: [{
100 file: 'index.html',
101 find: '<button type="button" class="imp-chip" data-view="settings" aria-pressed="false" data-i18n="social.settings">Settings</button>',
102 with: '<button type="button" class="imp-chip" data-view="settings" aria-pressed="false" data-i18n="social.settings">Settings</button>\n'
103 + '\t\t\t\t\t<button type="button" class="imp-chip" data-view="groups" aria-pressed="false">Groups</button>',
104 }],
105 noask: [{
106 file: 'js/mobile.js',
107 re: /var NO_ASK\s*=\s*\{[^}]*\}/,
108 with: 'var NO_ASK = { compose: 1, tools: 1, trash: 1 }',
109 }],
110 // Renamed AND moved. The panel still works; every panel number already
111 // gathered under 16 now means something else.
112 moveord: [{
113 file: 'js/telemetry.js',
114 re: /'social', 'pending'\]/,
115 with: "'pending', 'social']",
116 }],
117 // A panel that was already open when the app booted is never `show`n, so
118 // nothing else would ever call its `onOpen`.
119 bootblind: [{
120 file: 'js/daimond.js',
121 find: "\t\tif (window.DaimondSocial && DaimondPanels.isOpen('social')) { DaimondSocial.onOpen(); postBadge(); }",
122 with: "\t\tif (false) { DaimondSocial.onOpen(); postBadge(); }",
123 }],
124 // A badge that draws its zero. Everything else about it stays right, which
125 // is the point: this is the check that tells "it counts" from "it is a mark
126 // you have to read before you can ignore it".
127 zerobadge: [{
128 file: 'js/daimond.js',
129 find: "\t\t\tb.textContent = n ? String(n) : '';\n\t\t\tb.hidden = !n;",
130 with: "\t\t\tb.textContent = String(n);\n\t\t\tb.hidden = false;",
131 }],
132 // It counts what is in front of the reader. The number is otherwise correct.
133 badgeblind: [{
134 file: 'js/daimond.js',
135 find: '\t\t\t\tif (!n || visible(id)) return;',
136 with: '\t\t\t\tif (!n) return;',
137 }],
138 // Looking at the panel does not clear it, so the mark outlives the thing it
139 // was about and stops meaning anything.
140 badgestuck: [{
141 file: 'js/daimond.js',
142 find: "\t\t\tseen: function (id) {\n\t\t\t\tif (!(counts[id] | 0)) return;",
143 with: "\t\t\tseen: function (id) {\n\t\t\t\tif (true) return;",
144 }],
145 // R4 off: a message may draw as many references as it carries.
146 fiveref: [{
147 file: 'js/improve.js',
148 find: 'for (var i = 0; i < list.length && out.length < 4; i++) {',
149 with: 'for (var i = 0; i < list.length; i++) {',
150 }],
151 // R5 off: every chip resolves as it is drawn. Ten proposal chips on an inbox
152 // opening is ten metered requests against the reader's own allowance.
153 eagerref: [{
154 file: 'js/improve.js',
155 find: '\t\trefs.forEach(function (r) { host.appendChild(refChip(r)); });',
156 with: '\t\trefs.forEach(function (r) { host.appendChild(refChip(r)); resolve(r); });',
157 }],
158 // R1 off: the sender names the thing, and their words are drawn where the
159 // forge record's title goes.
160 senderlabel: [{
161 file: 'js/improve.js',
162 find: "\t\tname.textContent = refName(ref);",
163 with: "\t\tname.textContent = ref.said || refName(ref);",
164 }],
165 // R3 off: the chip becomes a link.
166 refhref: [{
167 file: 'js/improve.js',
168 find: "\t\tbox.dataset.ref = ref.id;",
169 with: "\t\tbox.dataset.ref = ref.id;\n\t\tvar a = document.createElement('a');\n"
170 + "\t\ta.href = 'https://example.invalid/' + ref.id;\n\t\tbox.appendChild(a);",
171 }],
172 // Decision 17 undone: two things called Message.
173 msgname: [{
174 file: 'i18n/en.js',
175 find: "\t'panel.msg': 'Email message',",
176 with: "\t'panel.msg': 'Message',",
177 }],
178};
179
180if (BREAK && !BREAKS[BREAK]) {
181 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
182 process.exit(2);
183}
184
185/// One edit, or a hard stop. An anchor that is not there exactly once changed
186/// nothing, and the run below would prove the opposite of what it claims.
187function edit(src, spec) {
188 if (spec.re) {
189 const n = (src.match(new RegExp(spec.re.source, 'g')) || []).length;
190 if (n !== 1) {
191 console.error(`break '${BREAK}': the shape ${spec.re} matches ${n} time(s) in ${spec.file}.`);
192 process.exit(2);
193 }
194 return src.replace(spec.re, spec.with);
195 }
196 const n = src.split(spec.find).length - 1;
197 if (n !== 1) {
198 console.error(`break '${BREAK}': the anchor appears ${n} time(s) in ${spec.file}.`);
199 process.exit(2);
200 }
201 return src.replace(spec.find, spec.with);
202}
203
204/// ONE BODY PER FILE. Playwright hands a request to the LAST route registered
205/// for its URL, so a two-edit break registered as two routes ships only its
206/// second edit — and still goes red, for half the reason it claims.
207function damagedFiles() {
208 const byFile = new Map();
209 for (const spec of (BREAKS[BREAK] || [])) {
210 const src = byFile.has(spec.file) ? byFile.get(spec.file)
211 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
212 byFile.set(spec.file, edit(src, spec));
213 }
214 return byFile;
215}
216
217// The damaged bodies, computed ONCE and used for both the browser and the two
218// source checks below.
219//
220// READ THROUGH THE BREAK, not off the disk. A source check that reads the file
221// while the browser is served a damaged copy is a check no break can redden --
222// which is exactly what `--break bootblind` did on 2026-08-17: it went green,
223// and a green break run means the check it aims at is checking nothing.
224const served = damagedFiles();
225const source = (rel) => served.get(rel) ?? fs.readFileSync(path.join(WWW, rel), 'utf8');
226
227const routeBreaks = async (pg) => {
228 if (!BREAK) return;
229 for (const [file, body] of served) {
230 // `index.html` is asked for as the bare root, which is what `goto` is given,
231 // so matching it by name would register a route nothing ever hits -- and a
232 // break that changes nothing goes GREEN, which is the one outcome a break
233 // run must never produce.
234 const at = file === 'index.html'
235 ? (u) => u.pathname === '/' || u.pathname === '/index.html'
236 : '**/' + file;
237 await pg.route(at, r => r.fulfill({
238 status: 200,
239 contentType: file.endsWith('.css') ? 'text/css'
240 : file.endsWith('.html') ? 'text/html; charset=utf-8'
241 : 'application/javascript',
242 body,
243 }));
244 }
245};
246
247const ok = [], bad = [];
248const check = (name, pass, detail) => {
249 (pass ? ok : bad).push(name);
250 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
251};
252const sleep = ms => new Promise(r => setTimeout(r, ms));
253
254// ── The source, read as source ───────────────────────────────────────
255//
256// Two of the four seams are observable by driving the app and two are not. The
257// boot dispatch fires before any script this file could install, and there is
258// nothing downstream of it that a missing call would change — `start()` renders
259// the panel either way. So it is read out of the file, and SAID to be read out
260// of the file: a source check that is described as a behaviour check is the
261// same lie as a green run that proved nothing.
262const SRC = {
263 daimond: source('js/daimond.js'),
264 telemetry: source('js/telemetry.js'),
265};
266
267// ── Driving ──────────────────────────────────────────────────────────
268
269const s = await open({
270 name: 'socialtester',
271 profile: scratch('pw', 'social' + (BREAK ? '-' + BREAK : '')),
272 signIn: true,
273 connect: false,
274 route: routeBreaks,
275});
276const { page } = s;
277
278// Every request the panel's own door sees, so R5 is counted rather than assumed.
279let asked = 0;
280await page.route('**/api/improve**', (route) => {
281 asked++;
282 route.fulfill({
283 status: 200,
284 contentType: 'application/json',
285 body: JSON.stringify({ error: 'absent' }),
286 });
287});
288
289// A phone. `matchMedia('(max-width:760px)')` flips and the shell takes over.
290await page.setViewportSize({ width: 390, height: 844 });
291// Headless does not reliably advance CSS transitions, so a measured mid-flight
292// value is meaningless. Every assertion below reads a settled state.
293await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' });
294await sleep(500);
295
296/// A box, in viewport coordinates, or nulls. The one measurement this file
297/// trusts.
298const boxOf = (sel) => page.evaluate((q) => {
299 const e = document.querySelector(q);
300 if (!e) return null;
301 const b = e.getBoundingClientRect();
302 return { x: b.x, y: b.y, w: b.width, h: b.height };
303}, sel);
304
305/// Is it really on the screen: real area, and its centre inside the viewport.
306/// A element with a box at (-9999, 0) is off screen and every ancestry check in
307/// the world says it is fine.
308const onScreen = (b) => !!b && b.w > 1 && b.h > 1
309 && b.x + b.w / 2 > 0 && b.x + b.w / 2 < 390
310 && b.y + b.h / 2 > 0 && b.y + b.h / 2 < 844;
311
312try {
313
314// ── 1. The panel, renamed, and its four seams ────────────────────────
315
316// COUNTED first. Everything below asks whether this element is visible, and an
317// absent element answers "hidden" to that question.
318const panels = await page.locator('#panel-social').count();
319check('the panel is in the markup, exactly once', panels === 1, `${panels} found`);
320
321// FIVE, and the fifth is not decision 13's. That decision named four — Messages,
322// People, Notes, Proposals — and §5.5 of the same plan reserved room for sections
323// still to arrive, so the set was never closed at four. Share was added on
324// 2026-08-17 with the share carrier of `dev/SOCIAL_OFFICE_CONTRACT.md`: `share.js`
325// could already seal a Diamond to one person, choose a carrier and write a
326// `.dshare` out, and NOTHING IN THE APP CALLED ANY OF IT. It sits third, beside
327// People, because a share goes to somebody.
328//
329// ORDER AND ARITY BOTH, which is why this is an equality against a literal list
330// and not a membership test. A chip that has gone, two that have swapped, and a
331// sixth nobody decided on are three different faults, and this must redden for
332// every one of them — `--break nochip`, `--break swapchip`, `--break sixthchip`.
333const chipViews = await page.evaluate(() =>
334 [...document.querySelectorAll('#panel-social .imp-chip[data-view]')].map(c => c.dataset.view));
335check('its head carries the five chips: Messages, People, Share, Proposals, Settings',
336 JSON.stringify(chipViews) === JSON.stringify(['messages', 'people', 'share', 'proposals', 'settings']),
337 chipViews.join(' ') || 'none');
338
339check('the panel declares itself to the layout engine as `social`',
340 await page.evaluate(() => {
341 const e = document.getElementById('panel-social');
342 return !!e && e.dataset.panel === 'social' && e.dataset.zone === 'dock';
343 }));
344
345// 1a. ON A PHONE, ASKED FOR, SOMETHING IS VISIBLE.
346//
347// NOT `body.dataset.mpanel`, which is a fact about the floor and says nothing
348// about what the reader was shown — the exact check that let a blank screen
349// ship. Real geometry, on the panel and on a control inside it.
350await page.evaluate(() => { window.DaimondPanels.hide('social'); });
351await sleep(250);
352await page.evaluate(() => { window.DaimondPanels.show('social'); });
353await sleep(600);
354
355const panelBox = await boxOf('#panel-social');
356check('phone: the panel itself has a real box on the screen',
357 onScreen(panelBox), JSON.stringify(panelBox));
358
359const chipBox = await boxOf('#panel-social .imp-chip[data-view="proposals"]');
360check('phone: and a control inside it is on the screen too',
361 onScreen(chipBox), JSON.stringify(chipBox));
362
363const headBox = await boxOf('#panel-social .railhead');
364check('phone: the head, which is what names the panel, is on the screen',
365 onScreen(headBox), JSON.stringify(headBox));
366
367check('phone: and the head says Social, from the catalogue rather than the markup',
368 (await page.evaluate(() =>
369 (document.querySelector('#panel-social .railhead [role="heading"]') || {}).textContent || ''
370 )).trim() === 'Social');
371
372await shot(s, 'social-phone' + (BREAK ? '-' + BREAK : ''));
373
374// 1b. NO_ASK. The pill is COUNTED before it is asked whether it is hidden.
375const askCount = await page.locator('#msheet .msheet-ask, #msheet-ask').count();
376check('the sheet has an ask pill to withhold in the first place', askCount >= 1,
377 `${askCount} found`);
378const askShown = await page.evaluate(() => {
379 const e = document.querySelector('#msheet .msheet-ask, #msheet-ask');
380 if (!e) return null;
381 const b = e.getBoundingClientRect();
382 return b.width > 1 && b.height > 1;
383});
384check('and over the Social panel it is not offered: the panel is already a box you write in',
385 askShown === false, 'ask pill drawn: ' + askShown);
386
387// 1c. The telemetry ordinal did not move.
388const ord = await page.evaluate(() => {
389 const T = window.DaimondTelemetry;
390 if (!T) return null;
391 return { social: T.ordinal(T.PANELS, 'social'), pending: T.ordinal(T.PANELS, 'pending'),
392 improve: T.ordinal(T.PANELS, 'improve') };
393});
394check('telemetry: `social` sits where `improve` sat, at 16', ord && ord.social === 16,
395 JSON.stringify(ord));
396check('telemetry: and nothing after it moved — `pending` is still 17',
397 ord && ord.pending === 17, JSON.stringify(ord));
398check('telemetry: the old name is gone, so it reports as "something else" and not as a panel',
399 ord && ord.improve === 0, JSON.stringify(ord));
400
401// 1d. The onOpen dispatches. Two are driven; the third is READ.
402const told = await page.evaluate(async () => {
403 let n = 0;
404 window.DaimondSocial.watch(() => { n++; });
405 window.DaimondPanels.hide('social');
406 await new Promise(r => setTimeout(r, 250));
407 window.DaimondPanels.show('social');
408 await new Promise(r => setTimeout(r, 400));
409 return n;
410});
411check('opening the panel tells the panel, so it draws rather than sitting empty', told >= 1,
412 `${told} call(s)`);
413
414// READ OUT OF THE FILE, and said to be. The boot dispatch fires before any
415// script this file could install, and `start()` renders either way, so there is
416// nothing downstream of it a driven run could measure.
417check('SOURCE: a panel already open at boot is told too (daimond.js)',
418 /window\.DaimondSocial && DaimondPanels\.isOpen\('social'\)[^\n]*DaimondSocial\.onOpen\(\)/
419 .test(SRC.daimond));
420
421// 1e. Every chip leads somewhere with something in it.
422for (const view of ['messages', 'people', 'proposals', 'settings']) {
423 const seen = await page.evaluate(async (v) => {
424 window.DaimondSocial.show(v);
425 await new Promise(r => setTimeout(r, 350));
426 const shown = [...document.querySelectorAll('#panel-social .imp-view')]
427 .filter(e => !e.hidden);
428 if (shown.length !== 1) return { one: false, n: shown.length };
429 const e = shown[0];
430 const b = e.getBoundingClientRect();
431 // SOMETHING IS VISIBLE: a box with area, and text a reader can read
432 // inside it. An empty view with a box is the blank screen again.
433 const words = (e.innerText || '').trim();
434 return { one: true, id: e.id, w: b.width, h: b.height, words: words.slice(0, 80),
435 any: words.length > 0 };
436 }, view);
437 check(`the ${view} chip shows exactly one view`, seen.one, `${seen.n} shown`);
438 check(`the ${view} view has a real box on the phone`, seen.w > 1 && seen.h > 20,
439 JSON.stringify(seen));
440 check(`the ${view} view says something a reader can read`, !!seen.any,
441 seen.words || '(nothing)');
442 // And a key is never on the screen in place of a sentence.
443 check(`the ${view} view says words, not a key`, !/^[a-z]+\.[a-z_.]+$/.test(seen.words || ''),
444 seen.words);
445}
446
447// ── 2. The dock count badge ──────────────────────────────────────────
448
449const TITLE = await page.evaluate(() => document.title);
450
451// Nothing has arrived, so there is no mark. COUNTED, then measured: "no badge"
452// and "a badge that draws an empty string" are the same thing to a reader and
453// only one of them is what the idiom asks for.
454const quiet = await page.evaluate(() =>
455 [...document.querySelectorAll('.dock-count')]
456 .map(e => ({ t: e.textContent, hidden: e.hidden, h: e.getBoundingClientRect().height })));
457check('with nothing waiting, no count is drawn anywhere',
458 quiet.every(b => b.t === '' && (b.hidden || b.h < 1)), JSON.stringify(quiet));
459check('and the tab title carries no count either', !/^\(\d/.test(TITLE), TITLE);
460
461// The Mail panel is put away, so an arrival is something the reader is NOT
462// looking at.
463await page.evaluate(() => { window.DaimondPanels.hide('mail'); window.DaimondPanels.show('social'); });
464await sleep(400);
465
466// mail.js's own announcement, with mail.js's own definition of an arrival:
467// what came in above the mark the fetch started from, with backfills and
468// uid-validity rebuilds already fenced out of it.
469await page.evaluate(() => window.dispatchEvent(new CustomEvent('daimond:mail-arrived', {
470 detail: { mailbox: 'a@example.com', folder: 'INBOX', count: 3, uids: [7, 8, 9] },
471})));
472await sleep(400);
473
474check('mail arriving while you are elsewhere is counted',
475 await page.evaluate(() => window.DaimondBadge.count('mail')) === 3);
476
477const drawn = await page.evaluate(() => {
478 const hosts = [...document.querySelectorAll('#panel-tags .ptag[data-panel="mail"]')];
479 return hosts.map((h) => {
480 const b = h.querySelector('.dock-count');
481 if (!b) return { where: h.className, badge: null };
482 const r = b.getBoundingClientRect();
483 return { where: h.className, text: b.textContent, hidden: b.hidden,
484 x: r.x, y: r.y, w: r.width, h: r.height };
485 });
486});
487const lit = drawn.filter(d => d.badge !== null && d.text === '3'
488 && onScreen({ x: d.x, y: d.y, w: d.w, h: d.h }));
489check('and the count is DRAWN, on screen, on a control that opens the panel',
490 lit.length >= 1, JSON.stringify(drawn));
491check('the tab title carries it, for a tab that is not the one in front',
492 (await page.evaluate(() => document.title)).startsWith('(3) '),
493 await page.evaluate(() => document.title));
494
495// A chip row rebuild throws the chips away. The count must come back with them.
496await page.evaluate(() => window.DaimondPanels.reflow());
497await sleep(350);
498check('and it survives the chip row being rebuilt',
499 await page.evaluate(() => {
500 const b = document.querySelector('#panel-tags .ptag[data-panel="mail"] .dock-count');
501 return !!b && b.textContent === '3';
502 }));
503
504// Looking at it is what clears it.
505await page.evaluate(() => { window.DaimondPanels.show('mail'); });
506await sleep(500);
507check('looking at the panel clears the count',
508 await page.evaluate(() => window.DaimondBadge.count('mail')) === 0);
509const cleared = await page.evaluate(() =>
510 [...document.querySelectorAll('.dock-count')].map(e => ({ t: e.textContent, hidden: e.hidden })));
511check('and NEVER leaves a zero on screen: an empty string, and the mark put away',
512 cleared.every(b => b.t === '' && b.hidden), JSON.stringify(cleared));
513check('the tab title goes back to what it was',
514 await page.evaluate(() => document.title) === TITLE);
515
516// It does not count what is in front of you. Mail is on screen now.
517await page.evaluate(() => window.dispatchEvent(new CustomEvent('daimond:mail-arrived', {
518 detail: { mailbox: 'a@example.com', folder: 'INBOX', count: 2, uids: [10, 11] },
519})));
520await sleep(350);
521check('mail arriving at a panel you are looking at raises no mark',
522 await page.evaluate(() => window.DaimondBadge.count('mail')) === 0,
523 'count=' + await page.evaluate(() => window.DaimondBadge.count('mail')));
524
525await page.evaluate(() => { window.DaimondPanels.hide('mail'); window.DaimondPanels.show('social'); });
526await sleep(350);
527
528// ── 3. The reference chip, and the five rules ────────────────────────
529
530// R4 — at most four, and only the four kinds this build knows.
531const cleaned = await page.evaluate(() => window.DaimondRefs.clean([
532 { kind: 'proposal', id: '1' }, { kind: 'proposal', id: '2' },
533 { kind: 'build', id: 'abc' }, { kind: 'panel', id: 'spend' },
534 { kind: 'guide', id: 'sync.html' },
535 { kind: 'chat', id: 'secret' }, { kind: 'file', id: '/home/someone/x' },
536 { kind: 'proposal' }, 'not an object', null,
537]));
538check('R4: a message may carry four references and no more', cleaned.length === 4,
539 `${cleaned.length}: ` + cleaned.map(r => r.kind).join(' '));
540check('R4: and a kind this build does not know is dropped, not drawn',
541 cleaned.every(r => ['proposal', 'build', 'panel', 'guide'].includes(r.kind)),
542 JSON.stringify(cleaned));
543check('R4: a reference with no id is dropped',
544 (await page.evaluate(() => window.DaimondRefs.clean([{ kind: 'proposal' }]).length)) === 0);
545check('a reference reduces to three fields and no fourth',
546 cleaned.every(r => Object.keys(r).sort().join(',') === 'id,kind,said'),
547 JSON.stringify(Object.keys(cleaned[0] || {})));
548
549// Draw them somewhere real, inside the panel, so what is measured is what a
550// reader would see.
551const before = asked;
552// NOT inside `#social-messages-list` or `#social-people-list`: those are
553// js/post.js's and js/trust.js's own regions and each is cleared wholesale on
554// every redraw, so a probe parked in one is gone by the time it is measured —
555// which is how this check first went vacuously green, counting nought chips and
556// asserting nought of them were links.
557await page.evaluate(() => {
558 const host = document.createElement('div');
559 host.id = 'ref-probe';
560 document.getElementById('panel-social').appendChild(host);
561 window.DaimondRefs.forget();
562 window.DaimondRefs.draw(host, [
563 { kind: 'proposal', id: '14', fallback_label: 'the file view scrolls wrong' },
564 { kind: 'proposal', id: '15', fallback_label: 'a second one' },
565 { kind: 'panel', id: 'spend' },
566 { kind: 'guide', id: 'not a page', fallback_label: 'how sync works' },
567 ]);
568});
569await page.evaluate(() => window.DaimondSocial.show('messages'));
570await sleep(500);
571
572const chipCount = await page.locator('#ref-probe .ref-chip').count();
573check('four references draw four chips', chipCount === 4, `${chipCount} drawn`);
574const firstChip = await boxOf('#ref-probe .ref-chip');
575check('and a chip is really on the screen', onScreen(firstChip), JSON.stringify(firstChip));
576
577// R5 — nothing is read until somebody asks.
578await sleep(400);
579check('R5: drawing four proposal chips reads nothing from the forge',
580 asked === before, `${asked - before} request(s) went out`);
581
582// R3 — not a URL, anywhere on any chip. Both of these are assertions that
583// something is ABSENT, and an absent chip satisfies them for the wrong reason,
584// so each carries the chip count it was measured over.
585check('R3: no chip is a link', chipCount === 4 && await page.evaluate(() =>
586 document.querySelectorAll('#ref-probe [href], #ref-probe a').length) === 0,
587 `over ${chipCount} chip(s)`);
588
589// R1 — the sender's words are NOT the name of the thing.
590const names = await page.evaluate(() =>
591 [...document.querySelectorAll('#ref-probe .ref-name')].map(e => e.textContent));
592check('R1: the sender did not get to name any of them',
593 names.length === 4 && !names.some(n => /file view scrolls wrong|a second one|how sync works/.test(n)),
594 names.join(' | ') || '(no chips to read)');
595
596// Pressing ONE chip reads ONE thing.
597await page.locator('#ref-probe .ref-chip[data-ref="14"] .ref-act').click();
598await sleep(700);
599check('R5: pressing one chip reads exactly one thing', asked - before === 1,
600 `${asked - before} request(s)`);
601
602// R2 — the forge refused `absent`, which covers "no such repository" AND "it is
603// private". The chip says the one sentence that is true in both cases and
604// NOTHING sharper.
605const shut = await page.evaluate(() => {
606 const c = document.querySelector('#ref-probe .ref-chip[data-ref="14"]');
607 return { shut: c.classList.contains('shut'),
608 note: (c.querySelector('.ref-note') || {}).textContent || '',
609 said: (c.querySelector('.ref-said') || {}).textContent || '',
610 acts: c.querySelectorAll('.ref-act').length };
611});
612check('R2: a reference that cannot be opened is drawn shut', shut.shut, JSON.stringify(shut));
613check('R2: and says only that the repository is not available',
614 /not available to you/.test(shut.note), shut.note);
615check('R2: it never says the thing does not exist',
616 !/no such|not found|does not exist|private/i.test(shut.note), shut.note);
617check('R1: only NOW are the sender\'s words drawn, and framed as the sender\'s',
618 /Described as:/.test(shut.said) && /file view scrolls wrong/.test(shut.said), shut.said);
619check('a shut chip offers no control, so nothing on it can be pressed twice',
620 shut.acts === 0, `${shut.acts} control(s)`);
621
622// A panel reference is navigation and nothing else: it discloses nothing, reads
623// nothing, and opens what it names.
624await page.evaluate(() => { window.DaimondPanels.hide('spend'); });
625await sleep(250);
626const askedBeforePanel = asked;
627await page.locator('#ref-probe .ref-chip[data-kind="panel"] .ref-act').click();
628await sleep(400);
629check('a panel reference reads nothing at all', asked === askedBeforePanel);
630await page.locator('#ref-probe .ref-chip[data-kind="panel"] .ref-act').click();
631await sleep(600);
632check('and pressing it opens the panel it names',
633 await page.evaluate(() => window.DaimondPanels.isOpen('spend')));
634
635await page.evaluate(() => { window.DaimondPanels.hide('spend'); window.DaimondPanels.show('social'); });
636await sleep(300);
637
638// A build reference needs no request either: this build's own stamp is in hand.
639const buildSays = await page.evaluate(async () => {
640 const host = document.createElement('div');
641 host.id = 'ref-build';
642 document.getElementById('panel-social').appendChild(host);
643 window.DaimondRefs.forget();
644 const j = await fetch('build.json', { cache: 'no-store' }).then(r => r.json());
645 window.DaimondRefs.draw(host, [
646 { kind: 'build', id: j.build },
647 { kind: 'build', id: 'ffffffffffff' },
648 ]);
649 for (const b of host.querySelectorAll('.ref-act')) b.click();
650 await new Promise(r => setTimeout(r, 400));
651 return [...host.querySelectorAll('.ref-chip')].map(c => ({
652 name: (c.querySelector('.ref-name') || {}).textContent || '',
653 note: (c.querySelector('.ref-note') || {}).textContent || '',
654 act: (c.querySelector('.ref-act') || {}).textContent || '',
655 }));
656});
657check('a build you are on says so, and offers nothing to press',
658 /build you are on/i.test(buildSays[0].note) && buildSays[0].act === '',
659 JSON.stringify(buildSays[0]));
660check('a build you are not on says which one you ARE on, and offers the update',
661 /You are on build/i.test(buildSays[1].note) && /Update/i.test(buildSays[1].act),
662 JSON.stringify(buildSays[1]));
663
664// ── 4. Decision 17: two things called Message, no longer ─────────────
665
666// The panel's own NAME, which is what the chip row, the gallery, the palette
667// and the phone sheet's title all read. `#msg-title` is not it: that is written
668// by mail.js and carries the subject of whatever is open.
669const msgLabel = await page.evaluate(() => ({
670 markup: (document.getElementById('panel-msg') || {}).dataset.label,
671 catalogue: window.DaimondI18n ? window.DaimondI18n.t('panel.msg') : null,
672}));
673check('the email reading panel is called Email message, in the catalogue',
674 msgLabel.catalogue === 'Email message', JSON.stringify(msgLabel));
675check('and in the markup it falls back to, which is what shows before the first paint',
676 msgLabel.markup === 'Email message', JSON.stringify(msgLabel));
677check('and the Social panel has the plain word Messages to itself',
678 (await page.evaluate(() =>
679 (document.querySelector('#panel-social .imp-chip[data-view="messages"]') || {}).textContent
680 )) === 'Messages');
681
682} finally {
683 await shot(s, 'social' + (BREAK ? '-' + BREAK : ''));
684 const errs = s.errs.filter(e => !/Failed to load resource|status of 4\d\d/.test(e));
685 check('no console errors along the way', errs.length === 0, errs.slice(0, 3).join(' | '));
686 await s.close();
687}
688
689console.log(`\n${ok.length} ok, ${bad.length} failed`);
690if (bad.length) {
691 console.log('failed: ' + bad.join('; '));
692 process.exit(1);
693}
694if (BREAK) {
695 console.log(`\nbreak '${BREAK}' produced a GREEN run, which means the check it is `
696 + 'aimed at is not checking anything.');
697 process.exit(1);
698}