Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_lifelog.mjs

104 KiB, 1 run

created by r2519314175:505, 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_lifelog.mjs — drive the lifelog capp in a real Diamond and hold it to its numbers.
2//
3// WHERE THIS FILE BELONGS, and it is now there. It lives in `dev/` and must stay: `dev/run_all.sh`
4// enumerates `dev/verify_*.mjs` and nothing else, so anywhere else it is never in the gate; and
5// `verify/lib.mjs` walks `www/` for the transparency manifest, excluding only the handful of
6// files named in its `EXCLUDE`, so from inside `www/` its bytes would be SEALED INTO THE PUBLISHED
7// BUNDLE and served to every user — which is where it used to sit. Nothing below writes a path
8// down: the repo root is derived from this file's own URL, `harness.mjs` is imported through it,
9// and screenshots go to the harness scratch root. `dev/gate.sh` runs the suite inside a `git
10// worktree` AT A DIFFERENT PATH, so an absolute path here would read the MAIN tree's page while
11// driving the worktree's app — a verifier measuring one tree and reporting on another.
12//
13// node dev/verify_lifelog.mjs # clean
14// node dev/verify_lifelog.mjs --break <name> # and each property proved red first
15//
16// WHAT IT ASSERTS, and the rule it is written under: assert MEANING, not arity. An earlier
17// version of this file counted elements — "more than six tiles", "more than five rows" — and
18// its headline check, "A TAP REACHES THE DISK", tested the shard for `"src":"preset"`. The
19// FIXTURE BELOW SEEDS PRESET LINES, so that check was green before the tap and would have
20// stayed green if the tile had done nothing at all. It is now a before-and-after on the ids in
21// the file. That is the failure this project keeps paying for and it is worth naming: a
22// verifier that passes for the wrong reason is worse than no verifier, because it is believed.
23//
24// The properties, and the last five of the first eight carry the weight:
25//
26// 1. The page is inside its ceilings and asks for nothing it may not have (static, no browser).
27// 2. The frame is still up after `data`, so `rendered` named every content key of the crystal
28// — checked BOTH ways: the frame is there, and the channel's own key list covers the
29// crystal's keys including one the page has never heard of.
30// 3. It furnishes an empty Diamond with lanes it can be used from.
31// 4. A TAP REACHES THE DISK: a NEW line, with the right food, the right day, and every line
32// that was there before still there unchanged.
33// 5. A SCALE TILE LOGS THE ADJUSTED AMOUNT, not the tile's default.
34// 6. A DELETE LEAVES A TOMBSTONE and the row goes.
35// 7. THE ARITHMETIC IS RIGHT: today's energy, computed here from the fixture and the food
36// table, matches the number on screen to the calorie; and one lift's volume in a session
37// matches kg x reps summed here.
38// 8. The chart shows one bar per day that HAS something and none for the day that does not,
39// and the pie folds its ninth series into a neutral Other rather than inventing a hue.
40//
41// And the two the owner asked for after using it (notes6, items 1 and 2):
42//
43// 9. LOG AND LIFE ARE TWO VIEWS, NOT ONE MIXED ONE. Log has the tiles and the entries and no
44// chart; Life has the numbers and the charts and no tile pad; and the switch works BOTH
45// WAYS, because a mode a person cannot get out of is worse than the mix it replaced.
46// 10. A LANE CAN BE MADE FROM INSIDE THE PAGE, and the made lane is USABLE: it reaches the
47// disk, it arrives with tiles, pressing one puts a line in that lane's own shard, and Life
48// reads the line back. An empty one is refused with the reason on screen. And an EDIT of a
49// lane a daimon wrote keeps the catalogue, the formula and the tiles it has no vocabulary
50// for — the check that stops this feature eating the three lanes that ship.
51//
52// Screenshots go to the scratchpad (harness `scratch`), never into www/.
53import fs from 'node:fs';
54import path from 'node:path';
55import { fileURLToPath, pathToFileURL } from 'node:url';
56
57const HERE = path.dirname(fileURLToPath(import.meta.url));
58// Derived from this file, so it is the same tree whatever the working directory is: `run_all.sh`
59// invokes `node dev/verify_lifelog.mjs` from the repo root, and a person runs it from `dev/`.
60// This file lives in `dev/`, so the root is one up — and if the page is not under it, that is
61// said here rather than left to surface as an opaque ENOENT from a guessed second location.
62const ROOT = path.join(HERE, '..');
63const PAGE_PATH = path.join(ROOT, 'www', 'capps', 'lifelog', 'crystal.html');
64if (!fs.existsSync(PAGE_PATH)) {
65 console.error('verify_lifelog: no capp page at ' + PAGE_PATH
66 + '\nthis file must sit in the repo\'s `dev/`; it derives everything else from there.');
67 process.exit(2);
68}
69const H = await import(pathToFileURL(path.join(ROOT, 'dev', 'harness.mjs')).href);
70const { open, signInAs, connectMock, errors, scratch } = H;
71
72// src/tools.rs, CRYSTAL_PAGE_CAP_DEFAULT — 64 KiB until 2026-08-13, and the shipped page was
73// 1,500 bytes short of it. Read from there rather than restated, so a page that fits a ceiling
74// this file believes in but the engine does not is caught here instead of at delivery.
75const PAGE_CAP = (() => {
76 const src = fs.readFileSync(path.join(ROOT, 'src', 'tools.rs'), 'utf8');
77 const m = /CRYSTAL_PAGE_CAP_DEFAULT:\s*usize\s*=\s*(\d+)\s*\*\s*(\d+)/.exec(src);
78 if (!m) throw new Error('verify_lifelog: CRYSTAL_PAGE_CAP_DEFAULT not found in src/tools.rs');
79 return Number(m[1]) * Number(m[2]);
80})();
81let PAGE = fs.readFileSync(PAGE_PATH, 'utf8');
82const DIR = path.dirname(PAGE_PATH);
83/// What the template carries besides its page, by its own manifest.
84const MANIFEST = JSON.parse(fs.readFileSync(path.join(DIR, 'capp.json'), 'utf8'));
85const CARRIES = MANIFEST.files.filter(f => f !== 'crystal.html');
86const FILE = (rel) => fs.readFileSync(path.join(DIR, rel), 'utf8');
87
88// TWO WAYS A LIFELOG DIAMOND COMES INTO EXISTENCE, and both have to work:
89//
90// delivered the guide's button copies the template, files and all, so the lanes and the
91// catalogues are on disk before the page has run once. This is what a user gets.
92// seeded a daimon wrote `crystal.html` and nothing else, so the page finds no `index.json`
93// and furnishes the Diamond itself on first run.
94//
95// The default run is the DELIVERED one, because that is the path a user is on. `--seed` lays
96// down nothing but the page and exercises the fallback. Neither is a substitute for the other:
97// a run that only ever seeded would say nothing about the files this directory ships.
98// `--break noseed` damages the page's own seeding, which the delivered run never reaches, so it
99// implies `--seed`. A break that cannot run its target is a break that reports green.
100const SEED_MODE = process.argv.includes('--seed') || process.argv.includes('noseed');
101const SHOTS = process.env.SHOTS || path.dirname(scratch('shots', 'x'));
102fs.mkdirSync(SHOTS, { recursive: true });
103
104// The floor a filled surface's ink must clear against its own ground. 3.0 rather than 4.5, and
105// the reason is worth stating: the app resolves its own `accentText` against its own accent and
106// hands it to the page, so a threshold above what the APP's palette achieves would be this file
107// failing the whole product's colour scheme through a capp. What is being caught here is the
108// fault the owner reported — ink that does not invert at all, which lands between 1.0 and 2.0
109// — and 3.0 catches that with room to spare while leaving the palette's own choices alone.
110const CONTRAST_MIN = 3.0;
111
112const ok = [], bad = [];
113const check = (name, pass, detail) => {
114 (pass ? ok : bad).push(name);
115 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
116};
117
118// ── The breaks ────────────────────────────────────────────────────
119//
120// Here they go on the PAGE and not on the app, which is the opposite of
121// `dev/verify_capp.mjs` and right for the opposite reason: there, what is under test is what
122// the app REFUSES an untrusted page, so a break in the page would prove only that a page which
123// does not ask does not receive. Here the page IS the artefact, so the breaks are its own.
124const BREAKS = {
125 // The page reports only the keys it recognises. `habits` is in the crystal below and in no
126 // page's vocabulary, so the app must judge the page to be showing less than the Diamond
127 // holds and take the frame down. -> 2 goes red.
128 blindkeys: {
129 find: " for (k in D) if (D.hasOwnProperty(k) && k.charAt(0) !== '_') ks.push(k);\n"
130 + " post({ cmd: 'rendered', keys: ks });",
131 with: " for (k in D) if (D.hasOwnProperty(k) && 'title summary facts open'.indexOf(k) >= 0)"
132 + " ks.push(k);\n post({ cmd: 'rendered', keys: ks });",
133 },
134 // The log is kept in memory and never written. The SCREEN still shows the tap, which is
135 // exactly why the check reads the disk and not the DOM. -> 4, 5 and 6 go red, 2 does not.
136 nosave: {
137 find: "\t\tps.push(save('log/' + ln + '/' + ym2 + '.jsonl', by[ym2].join('\\n') + '\\n', 'append'));",
138 with: "\t\tps.push(Promise.resolve({ ok: true }));",
139 },
140 // The furniture arrives without the template tile it is named for. -> 3 goes red.
141 noseed: {
142 find: "\t\tsave('lanes/diet.json', JSON.stringify(diet), 'replace'),",
143 with: "\t\tsave('lanes/diet.json', JSON.stringify(diet).split('Porridge').join('X'), 'replace'),",
144 },
145 // The stepper's amount is thrown away and the tile logs its default. -> 5 goes red.
146 scalefixed: {
147 find: "\t\tvar tl = (l.tiles || [])[+v], f = cp(tl.f); f[tl.by] = S.amt;",
148 with: "\t\tvar tl = (l.tiles || [])[+v], f = cp(tl.f);",
149 },
150 // A catalogue number stops being per 100 g, so every derived field is 100x. -> 7a goes red.
151 unscaled: {
152 find: "\t\treturn v * base / (f.d.per || 100);",
153 with: "\t\treturn v * base;",
154 },
155 // The expression walker adds where it should multiply, so volume is kg+reps. -> 7b goes red.
156 badformula: {
157 find: "\t\tst.push(k.o === '+' ? a + b : k.o === '-' ? a - b : k.o === '*' ? a * b",
158 with: "\t\tst.push(k.o === '+' ? a + b : k.o === '-' ? a - b : k.o === '*' ? a + b",
159 },
160 // A bar is drawn for a bucket with nothing in it, so a gap in the log stops showing.
161 // -> 8a goes red.
162 phantombars: { find: "\t\tif (h > 0.5) {", with: "\t\tif (true) {" },
163 // The tail is never folded, so a twelfth food is a twelfth hue. -> 8b goes red.
164 allhues: { find: "\tif (out.length > 8) {", with: "\tif (false) {" },
165 // The page stops asking for height, so its bottom half is below the fold for ever.
166 // -> 'AND ITS HEIGHT CAME FROM THE PAGE'S OWN MESSAGE' goes red. It is the only break here
167 // that named no check when it was written, and it duly changed nothing: the check beside it
168 // reads `clientHeight`, which the surrounding layout satisfies on a desktop viewport with or
169 // without the message. A break that reports green is the defect this file exists to catch.
170 noheight: { find: "\tLASTH = h; post({ cmd: 'height', px: h });", with: "\tLASTH = h;" },
171
172 // ── The two the owner asked for ────────────
173 // Life draws the tile pad as well, so the two views are one mixed view again — which is the
174 // thing he reported. -> 'LIFE IS THE READING VIEW' goes red, 'LOG IS THE ENTRY VIEW' does not.
175 mixedviews: {
176 find: "function home() { return S.view === 'life' ? lifeView() : logView(); }",
177 with: "function home() { return S.view === 'life' ? pad() + lifeView() : logView(); }",
178 },
179 // The other direction of the same fault, and it needs its own break because `mixedviews`
180 // leaves Log alone: the entry view carries the numbers and the charts as well, which is
181 // exactly the page he was given. -> 'LOG IS THE ENTRY VIEW' goes red, 'LIFE IS' does not.
182 mixedlog: {
183 find: "\treturn (sc ? scopeBar(l, sc) : '') + pad() + restBar(l) + toastBar()",
184 with: "\treturn (sc ? scopeBar(l, sc) : '') + pad() + stats() + charts() + restBar(l) + toastBar()",
185 },
186 // The builder stops offering one of the types the ontology names, so a lane that wants a note
187 // on every entry cannot be made from the page at all. -> 'THE + OPENS A LANE BUILDER' goes
188 // red. `ref` is absent by design and is not in the list the check requires.
189 fewtypes: {
190 find: "var FTYPES = [['num', 'Number'], ['dur', 'Time'], ['enum', 'Choice'], ['bool', 'Yes/no'], ['text', 'Note']];",
191 with: "var FTYPES = [['num', 'Number'], ['dur', 'Time'], ['enum', 'Choice'], ['bool', 'Yes/no']];",
192 },
193 // The switch only ever goes one way, so a person who presses Life is in Life for good.
194 // -> 'AND THE SWITCH GOES BOTH WAYS' goes red.
195 stuckview: {
196 find: "\telse if (a === 'view') { S.view = v; S.tile = -1; S.scr = null; loadRange().then(draw); }",
197 with: "\telse if (a === 'view') { S.view = 'life'; S.tile = -1; S.scr = null; loadRange().then(draw); }",
198 },
199 // The lane is made in memory and never written, so it is gone on the next open. The SCREEN
200 // still shows it, which is why the check reads the disk. -> 'A LANE MADE FROM THE PAGE
201 // REACHES THE DISK' goes red.
202 nolanesave: {
203 find: "\tvar ps = [save('lanes/' + l.id + '.json', laneJson(l), 'replace')];",
204 with: "\tvar ps = [Promise.resolve({ ok: true })];",
205 },
206 // Nothing is checked before a lane is written, so a lane with no name and no fields is made
207 // — and it draws nothing for ever. -> 'AN EMPTY LANE IS REFUSED' goes red.
208 weaklane: {
209 find: "\tif (why) { toast(why, 1); draw(); return; }",
210 with: "\tif (0) { toast(why, 1); draw(); return; }",
211 },
212 // The daily target typed into the builder is dropped on the way to the file, so the number in
213 // Life has nothing to be measured against. -> 'A LANE MADE FROM THE PAGE REACHES THE DISK'
214 // goes red on its target.
215 notarget: {
216 find: "\t\tif (!r2.k || tv == null || (r2.t !== 'num' && r2.t !== 'dur')) continue;",
217 with: "\t\tif (true) continue;",
218 },
219 // A new lane arrives with an empty pad: every field it has and no way to press any of them.
220 // -> 'IT ARRIVES WITH TILES' and the shard check after it go red.
221 notiles: { find: "\tl.tiles = tiles;", with: "\tl.tiles = sc.edit ? tiles : [];" },
222 // The builder claims a vocabulary it has not got: a catalogue field and a computed field
223 // become ordinary rows, and saving flattens them. -> 'AN EDIT KEEPS WHAT THE DAIMON WROTE'
224 // goes red, on the diet lane that ships.
225 flatten: {
226 find: "\t\tif (d.d || d.t === 'ref') continue;",
227 with: "\t\tif (false) continue;",
228 },
229 // A redraw nobody asked for — a theme arriving, a toast clearing itself — no longer reads the
230 // half-built lane back out of the DOM, so it types over it. -> 'A HALF-BUILT LANE SURVIVES'
231 // and 'THE REDRAW A TOAST FORCES' both go red. This is the fault the run of 2026-08-14 found
232 // in the shipped page, on the entry form as well as the new screen.
233 losename: {
234 find: "\t\telse if (S.scr && S.scr.t === 'lane') readLane();",
235 with: "\t\telse if (S.scr && S.scr.t === 'lane') { }",
236 },
237 // The view switch goes back in a scroller with the lane names, where at 375px with four lanes
238 // it is clipped and nothing else notices. -> 'THE LOG/LIFE SWITCH IS WHOLLY ON THE SCREEN'
239 // goes red, and the page's own `scrollWidth` check stays green — which is the point of it.
240 clippedswitch: {
241 find: "\t\t+ '<div class=\"gap\"></div><div class=\"seg\">'",
242 with: "\t\t+ '<div class=\"gap\"></div><div class=\"chips\">'",
243 },
244 // ── The ontology the Gym lane is the hardest case of ────────────
245 //
246 // None of what follows is a gym feature. `p` is a plan, `prefill` is "the same as last
247 // time", `pick` is several choices at once, `rest` is a lane that counts between one
248 // commit and the next, and `end` is the second of two stamps. Each break below damages
249 // one of them, and each is chosen to survive every check but the one it proves — where
250 // that is not possible it is said so here and in the report.
251
252 // The lane files this directory ships stop being the lanes the page would seed, so a
253 // delivered Diamond and a seeded one are two different apps. The page still WORKS —
254 // nothing else moves. -> 'THE LANES IT CARRIES ARE THE LANES IT WOULD SEED' goes red.
255 laneDrift: {
256 find: "\t\tid: 'gym', n: 'Gym', dayStart: 0, sess: true, primary: 'lift', title: 'name',",
257 with: "\t\tid: 'gym', n: 'Gymnasium', dayStart: 0, sess: true, primary: 'lift', title: 'name',",
258 },
259 // The pending gate opens: a plan counts. This is the one break that is MEANT to redden
260 // more than one check, because "invisible to every number" is one property with four
261 // witnesses — the day's total, the pie, the streak and the bar for the day that has
262 // nothing real in it. A gate that leaked into only three of them would be worse.
263 // -> the energy check, 'AND NO NUMBER COUNTS IT' and the bar count all go red.
264 countpending: {
265 find: "\tfor (i = 0; i < es.length; i++) if (!es[i].p) o.push(es[i]);",
266 with: "\tfor (i = 0; i < es.length; i++) o.push(es[i]);",
267 },
268 // The other half, and the reason it needs its own break: a plan that is not DRAWN is
269 // also counted by nothing, and the aggregation checks above cannot tell the two apart.
270 // -> 'A PLANNED ENTRY IS DRAWN, AND DRAWN AS A PLAN' goes red, and nothing else.
271 nopendingrow: {
272 find: "\tfor (i = es.length - 1; i >= 0; i--) if (!es[i].of) rows.push(es[i]);",
273 with: "\tfor (i = es.length - 1; i >= 0; i--) if (!es[i].of && !es[i].p) rows.push(es[i]);",
274 },
275 // Nothing is ever carried forward, so every set opens empty and every food has to have
276 // its amount typed again. -> 'AND EACH OPENS ON THE LAST TIME' goes red.
277 noprefill: {
278 find: "\t\tif (!f || f.prefill !== 'last' || bag[f.k] != null) continue;",
279 with: "\t\tif (true) continue;",
280 },
281 // Prefill reads a row that has been written down and not yet done. One mistyped weight
282 // then propagates down the whole exercise, and the log fills with numbers nobody lifted.
283 // -> 'AND A ROW THAT IS ONLY A PLAN IS NOT WHAT THE NEXT ONE COPIES' goes red.
284 prefillpending: {
285 find: "\t\tif (e.p || (e.f || {})[l.primary] !== ref) continue;",
286 with: "\t\tif ((e.f || {})[l.primary] !== ref) continue;",
287 },
288 // The picker takes several and adds one, which is the difference between choosing your
289 // exercises and choosing an exercise five times. -> 'THE PICKER TAKES SEVERAL AT ONCE'
290 // goes red on what reaches the disk, not on what the screen highlighted.
291 singlepick: {
292 find: "\t\t\tif (!on6[k6]) continue;",
293 with: "\t\t\tif (!on6[k6] || es3.length) continue;",
294 },
295 // What the picker adds is committed rather than planned, so the session's totals count
296 // work that has not been done. -> 'EXERCISES ARE PICKED SEVERAL AT A TIME AND ARRIVE AS
297 // A PLAN' goes red.
298 notpending: { find: "\t\t\tif (sc.p) ne3.p = 1;", with: "\t\t\tif (0) ne3.p = 1;" },
299 // A session with no end stamp reads as finished, so the workout in progress is not one.
300 // -> 'A WORKOUT STARTS OPEN AND BECOMES THE SCREEN' goes red on the control it offers.
301 alwaysclosed: {
302 find: "function sessOpen(e) { return !!e && !e.end; }",
303 with: "function sessOpen(e) { return false; }",
304 },
305 // A length stops falling out of the two stamps, so a session that was never given a
306 // typed duration has none. -> 'A FINISH CLOSES IT, AND ITS LENGTH IS THE DISTANCE
307 // BETWEEN TWO STAMPS' goes red on yesterday's 1h 2m.
308 nospan: {
309 find: "\tif ((raw === '' || raw === undefined) && f.t === 'dur' && e.end) {",
310 with: "\tif (false) {",
311 },
312 // The rest clock will not restart while it is running, so the second exercise's set
313 // rests on whatever was left of the first's. -> 'THE REST CLOCK STARTS ON A TICK AND
314 // STARTS AGAIN ON THE NEXT' goes red.
315 reststicky: {
316 find: "\tREST.until = Date.now() + REST.secs * 1000;\n\trestTone(REST.secs);",
317 with: "\tif (REST.until > Date.now()) return;\n\tREST.until = Date.now() + REST.secs * 1000;\n\trestTone(REST.secs);",
318 },
319 // The tone plays at the moment of the tick instead of being scheduled two minutes out on
320 // the audio clock — which is what a countdown driven by `setInterval` would sound like,
321 // and it is silent in a backgrounded tab. -> 'AND THE DING IS SCHEDULED ON THE AUDIO
322 // CLOCK' goes red on the offsets, which is the only place the difference shows.
323 nodingsched: {
324 find: "\t\tt0 = c.currentTime + secs + i * 0.3;",
325 with: "\t\tt0 = c.currentTime + i * 0.3;",
326 },
327 // The scope stops selecting: the live list shows every set of every session there has
328 // ever been. -> THIS ONE REDDENS TWO, and deliberately: reviewing a past session and
329 // working through a live one are the SAME list with the same scope, which is the whole
330 // point of building the workout as the Log view rather than as a screen of its own. A
331 // break that reddened only one of them would mean there were two selections to get
332 // wrong.
333 scopeleak: {
334 find: "function scoped(l, sc) { return sc ? kids(l.id, sc.id) : inBucket(l.id, curB(), S.per); }",
335 with: "function scoped(l, sc) {\n\tif (!sc) return inBucket(l.id, curB(), S.per);\n\t"
336 + "var a = live(l.id), o = [], i;\n\tfor (i = 0; i < a.length; i++) if (a[i].of) o.push(a[i]);\n\t"
337 + "return o;\n}",
338 },
339 // A selected row keeps the ink it had on the ground it no longer has: the row goes dark,
340 // its contents do not invert, and what you have just chosen is the one thing you cannot
341 // read. This is the fault the owner reported, in the class it belongs to.
342 // -> 'SELECTED SURFACES INVERT THEIR INK' goes red.
343 dimselect: {
344 find: ".pickr.on .gap,.pickr.on .ev{color:var(--on)}",
345 with: ".pickr.on .never-matches{color:var(--on)}",
346 },
347 // The page goes back to taking the app's `accentText` on trust. In this palette that is a
348 // light tint of the accent — ink for accent-coloured text on the PAGE's ground, not ink
349 // for text on the accent — so every selected chip in the capp measures 1.83:1 and the
350 // thing you have just chosen is the thing you cannot read. This was the shipped state.
351 // -> 'SELECTED SURFACES INVERT THEIR INK' goes red, on the switch rather than the rows.
352 trustaccenttext: {
353 find: "\t\tif (!on || ratio(luma(on), la) < 3) on = ratio(0, la) >= ratio(1, la) ? '#141414' : '#ffffff';",
354 with: "\t\tif (!on) on = ratio(0, la) >= ratio(1, la) ? '#141414' : '#ffffff';",
355 },
356 // The pressed rule goes back BEHIND `.tile.acc .tn`. Both weigh (0,2,1), so the later one
357 // wins and a pressed accent tile draws accent ink on an accent ground: 1.00, and the name
358 // of the tile under the thumb is invisible for as long as the thumb is on it.
359 // -> 'SELECTED SURFACES INVERT THEIR INK' goes red, on the pressed tile.
360 dimpressed: {
361 find: ".tile:active .ts,.tile:active .tn{color:var(--on)}",
362 with: ".tile:active .never-matches{color:var(--on)}",
363 },
364 // The weight box is given a height a fraction under what its own type needs. The VALUE in
365 // it stays correct, so every check that reads a set's numbers back is green and the person
366 // holding the phone sees an empty box. -> 'AND EVERY CONTROL HAS MORE ROOM THAN ITS OWN
367 // TYPE NEEDS' goes red, and nothing that reads a value moves at all.
368 tightsetin: {
369 find: ".setin{width:56px;text-align:center;font-family:var(--mo);font-weight:600;padding:4px 5px;flex:none}",
370 with: ".setin{width:56px;text-align:center;font-family:var(--mo);font-weight:600;padding:4px 5px;flex:none;height:16px}",
371 },
372
373 // A field box that will not fit a phone, which is where a lane gets made. -> 'the lane
374 // builder does not scroll sideways on a phone' goes red.
375 widebuilder: {
376 find: ".fbox{border:1px solid var(--bd);border-radius:var(--rd);padding:9px 10px;margin:0 0 9px}",
377 with: ".fbox{border:1px solid var(--bd);border-radius:var(--rd);padding:9px 10px;margin:0 0 9px;min-width:520px}",
378 },
379};
380
381const BREAK = (() => {
382 const i = process.argv.indexOf('--break');
383 return i > 0 ? String(process.argv[i + 1] || '') : '';
384})();
385if (BREAK) {
386 const spec = BREAKS[BREAK];
387 if (!spec) {
388 console.error('no such break: ' + BREAK + '\nhave: ' + Object.keys(BREAKS).join(' '));
389 process.exit(2);
390 }
391 const n = PAGE.split(spec.find).length - 1;
392 if (n !== 1) {
393 console.error(`break '${BREAK}': the anchor appears ${n} times, so nothing was broken `
394 + 'and the run below would prove nothing.');
395 process.exit(2);
396 }
397 PAGE = PAGE.replace(spec.find, spec.with);
398 console.log(` (running with the page broken: ${BREAK})`);
399}
400
401// ── 1. What can be judged without a browser ───────────────────────
402//
403// The ceiling first, because a page over it is not refused at render time — it is refused at
404// WRITE time, so an oversized page is a capp that cannot be delivered at all, and every check
405// below would then be measuring whatever was in the Diamond instead.
406const bytes = Buffer.byteLength(PAGE, 'utf8');
407check('the page is inside the page ceiling the engine enforces', bytes <= PAGE_CAP,
408 bytes + ' / ' + PAGE_CAP + ' bytes, ' + (PAGE_CAP - bytes) + ' to spare');
409// The policy is `default-src 'none'; script-src 'unsafe-inline'` with `img-src data:`, so each
410// of these is a thing that cannot work rather than a thing that is discouraged. A page that
411// contained one would fail in the browser as a silent CSP refusal, which is the hardest kind of
412// failure to see in a screenshot.
413const FORBIDDEN = [
414 [/\bfetch\s*\(/, 'fetch'],
415 [/XMLHttpRequest|WebSocket|sendBeacon/, 'a network client'],
416 [/\b(local|session)Storage\b|indexedDB/i, 'storage, which throws in an opaque origin'],
417 [/\bnew\s+Function\b|\beval\s*\(/, 'eval, which the policy blocks'],
418 [/<iframe|<video|<audio|new\s+Worker|importScripts/, 'a nested frame, media or a worker'],
419 [/(src|href)\s*=\s*["']https?:/, 'an external asset'],
420 [/window\.open|\balert\s*\(|\bconfirm\s*\(|\bprompt\s*\(/, 'a popup the sandbox has not got'],
421];
422// Comments first, or the page's own note that `new Function` is blocked reads as a page using
423// it. A check that fires on the sentence explaining why the thing is absent is a check nobody
424// will believe the second time.
425const CODE = PAGE.replace(/\/\*[\s\S]*?\*\//g, '').split('\n')
426 .filter(l => !/^\s*\/\//.test(l)).join('\n');
427const found = FORBIDDEN.filter(([re]) => re.test(CODE)).map(([, n]) => n);
428check('the page asks for nothing the sandbox and the policy have not got',
429 found.length === 0, found.join(', ') || 'none of ' + FORBIDDEN.length);
430
431// The files the template carries, read as data rather than as bytes. A lane whose `show` names
432// a field that is not there draws a dash for ever and says nothing about why, so the drift is
433// worth catching in a file rather than in a screenshot — and this costs no browser.
434{
435 const gone = CARRIES.filter(f => !fs.existsSync(path.join(DIR, f)));
436 check('the template carries every file its manifest names', gone.length === 0,
437 gone.join(', ') || CARRIES.length + ' files');
438 const complaints = [];
439 if (!gone.length) {
440 const index = JSON.parse(FILE('index.json'));
441 const lanes = index.lanes.map(id => [id, JSON.parse(FILE('lanes/' + id + '.json'))]);
442 const named = CARRIES.filter(f => f.startsWith('lanes/')).map(f => f.slice(6, -5));
443 if (named.sort().join() !== index.lanes.slice().sort().join()) {
444 complaints.push('index.json names ' + index.lanes + ' but the manifest carries ' + named);
445 }
446 for (const [id, l] of lanes) {
447 const keys = new Set((l.fields || []).map(f => f.k));
448 const cat = fs.existsSync(path.join(DIR, 'cat/' + id + '.json'))
449 ? JSON.parse(FILE('cat/' + id + '.json')) : { items: [] };
450 const items = new Set((cat.items || []).map(x => x.id));
451 const wants = (k, why) => { if (k && !keys.has(k)) complaints.push(id + ': ' + why + ' names `' + k + '`'); };
452 if (l.id !== id) complaints.push(id + ': the file says id `' + l.id + '`');
453 wants(l.primary, 'primary'); wants(l.second, 'second'); wants(l.title, 'title');
454 (l.show || []).forEach(s => wants(String(s).split(':')[0], 'show'));
455 (l.sessShow || []).forEach(s => wants(String(s).split(':')[0], 'sessShow'));
456 (l.setFields || []).forEach(k => wants(k, 'setFields'));
457 (l.targets || []).forEach(t => wants(t.f, 'a target'));
458 for (const f of l.fields || []) {
459 if (f.d && f.d.c) {
460 wants(f.d.c, 'the catalogue field of `' + f.k + '`');
461 wants(f.d.by, 'the scaling field of `' + f.k + '`');
462 // Every item must carry the number the derived field reads off it, or that
463 // item silently contributes nothing to the day's total.
464 const missing = (cat.items || []).filter(x => !x.v || x.v[f.d.k] == null);
465 if (missing.length) {
466 complaints.push(id + ': ' + missing.length + ' catalogue items have no `'
467 + f.d.k + '` for field `' + f.k + '`');
468 }
469 }
470 if (f.d && f.d.e) {
471 (f.d.e.match(/[A-Za-z_][A-Za-z0-9_]*/g) || [])
472 .forEach(v => wants(v, 'the formula of `' + f.k + '`'));
473 }
474 }
475 for (const t of l.tiles || []) {
476 wants(t.by, 'tile `' + t.n + '`');
477 (t.fs || []).forEach(k => wants(k, 'tile `' + t.n + '`'));
478 for (const bag of [t.f].concat(t.e || []).filter(Boolean)) {
479 for (const k of Object.keys(bag)) {
480 wants(k, 'tile `' + t.n + '`');
481 const fd = (l.fields || []).find(x => x.k === k);
482 if (fd && fd.t === 'ref' && !items.has(bag[k])) {
483 complaints.push(id + ': tile `' + t.n + '` points at `' + bag[k]
484 + '`, which is not in the catalogue');
485 }
486 if (fd && fd.t === 'enum' && !(fd.o || []).includes(bag[k])) {
487 complaints.push(id + ': tile `' + t.n + '` sets ' + k + '=`' + bag[k]
488 + '`, which is not one of its choices');
489 }
490 }
491 }
492 }
493 }
494 }
495 check('every lane it carries points only at fields, catalogue items and choices that exist',
496 complaints.length === 0, complaints.slice(0, 4).join(' | ') || 'checked');
497}
498
499// The same lanes, twice over, and they have to be the same lanes. A delivered Diamond gets the
500// files in this directory; one a daimon wrote from the page alone gets `seed()`'s literals. The
501// two are maintained by hand and drift silently — a property added to the page's gym and not to
502// `lanes/gym.json` is a feature that works for one half of the users and is absent for the
503// other, and every check in this file that runs delivered would say nothing about it. So the
504// literals are lifted out of the page's own source and compared. `eval` here is node reading a
505// data literal out of a file this repo owns; the page itself may not have it and is held to
506// that separately.
507{
508 const grab = (name) => {
509 const i = PAGE.indexOf('var ' + name + ' = {');
510 if (i < 0) return null;
511 const j = PAGE.indexOf('{', i);
512 let d = 0, k = j;
513 for (; k < PAGE.length; k++) {
514 if (PAGE[k] === '{') d++;
515 else if (PAGE[k] === '}' && !--d) break;
516 }
517 return PAGE.slice(j, k + 1);
518 };
519 // The page's own per-100 g helper, in scope so the diet literal evaluates as it does there.
520 // eslint-disable-next-line no-unused-vars
521 const sc = (k, n, u) => ({ k, t: 'num', n, u, agg: 'sum', d: { c: 'food', k, per: 100, by: 'g' } });
522 const drift = [];
523 for (const id of ['diet', 'gym', 'body']) {
524 const src = grab(id);
525 if (!src) { drift.push(id + ': no literal in the page at all'); continue; }
526 let seeded = null;
527 try { seeded = eval('(' + src + ')'); } catch (e) { drift.push(id + ': ' + e.message); continue; }
528 const shipped = JSON.parse(FILE('lanes/' + id + '.json'));
529 const a = JSON.stringify(seeded), b = JSON.stringify(shipped);
530 if (a !== b) {
531 const pos = [...a].findIndex((c, n) => c !== b[n]);
532 drift.push(id + ' differs from ' + pos + ': carried `' + b.slice(pos, pos + 40)
533 + '` vs seeded `' + a.slice(pos, pos + 40) + '`');
534 }
535 }
536 check('THE LANES IT CARRIES ARE THE LANES IT WOULD SEED', drift.length === 0,
537 drift.join(' | ') || 'diet, gym and body identical either way');
538}
539
540// ── A fortnight of history, so the charts have something to draw ──
541const p2 = n => (n < 10 ? '0' : '') + n;
542const ymd = d => d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate());
543const isoAt = d => {
544 const o = -d.getTimezoneOffset(), s = o < 0 ? '-' : '+', a = Math.abs(o);
545 return ymd(d) + 'T' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds())
546 + s + p2(Math.floor(a / 60)) + ':' + p2(a % 60);
547};
548let n = 0;
549const nid = () => 'seed' + (++n).toString(36);
550const at = (back, h, m) => {
551 const d = new Date();
552 d.setDate(d.getDate() - back); d.setHours(h, m, 0, 0);
553 return d;
554};
555const ent = (back, h, m, f, src, of) => {
556 const d = at(back, h, m);
557 return { id: nid(), at: isoAt(d), day: ymd(d), of, src, f, w: Date.now() };
558};
559
560// The energy column of the page's own food table, so the oracle below is arithmetic done HERE
561// rather than the page's answer read back at it. If these drift from the page the check fails,
562// which is the point: they are the input to a number the page must reproduce.
563const KCAL = {
564 oats: 379, milk: 61, ban: 89, coff: 1, bread: 247, ched: 403, app: 52,
565 alm: 579, chick: 165, rice: 130, broc: 34, choc: 546,
566};
567
568const diet = [], gym = [], body = [];
569const BREAKFAST = [['oats', 80], ['milk', 250], ['ban', 120]];
570const LUNCH = [['bread', 120], ['ched', 40], ['app', 150]];
571const DINNER = [['chick', 200], ['rice', 250], ['broc', 150]];
572const GAP_DAY = 5; // one day with nothing in it, so a streak and a chart mean something
573let todayKcal = 0;
574for (let d = 13; d >= 0; d--) {
575 if (d === GAP_DAY) continue;
576 const eat = (back, h, m, food, g, meal, src) => {
577 diet.push(ent(back, h, m, { food, g, meal }, src));
578 if (back === 0) todayKcal += KCAL[food] * g / 100;
579 };
580 for (const [food, g] of BREAKFAST) eat(d, 7, 40, food, g, 'Breakfast', 'expand');
581 eat(d, 9, 15, 'coff', 250, 'Snack', 'preset');
582 for (const [food, g] of LUNCH) eat(d, 12, 50, food, g, 'Lunch', 'scale');
583 if (d % 3 === 0) eat(d, 16, 10, 'alm', 30, 'Snack', 'scale');
584 for (const [food, g] of DINNER) eat(d, 19, 30, food, g, 'Dinner', 'expand');
585 if (d % 4 === 1) eat(d, 21, 0, 'choc', 25, 'Snack', 'form');
586 body.push(ent(d, 7, 5, { kg: 82.4 - (13 - d) * 0.06, waist: 88 - (13 - d) * 0.05 }, 'form'));
587 body.push(ent(d, 7, 6, { sleep: (6.4 + (d % 3) * 0.55) * 3600, mood: ['Fine', 'Good', 'Great', 'Flat'][d % 4] }, 'form'));
588 // `d === 1` as well as every fifth day, and it is not decoration. THE BODY LANE'S DAY STARTS
589 // AT 04:00, so between midnight and four in the morning the lane's "today" is the calendar
590 // day before — and with a reading only every fifth day, that bucket had no resting heart rate
591 // in it and the check below went red on the clock rather than on the code. A verifier that
592 // fails for four hours a night is a verifier people learn to ignore.
593 if (d % 5 === 0 || d === 1) body.push(ent(d, 7, 8, { hr: 54 + (d % 3) }, 'form'));
594}
595// A PLAN, and not a record. Two of tonight's meals written down before they are eaten, and one
596// on the day the log has a hole in. Both are drawn and neither is counted, which is the whole
597// of `p` — and the choice of foods is deliberate: they are foods ALREADY eaten today, so the
598// pie's series are the same set either way and only the totals would move. A break that
599// counted them therefore has nowhere to hide behind a legend that happened to change shape.
600const PLANNED = [['chick', 300], ['rice', 300]];
601let plannedKcal = 0;
602for (const [food, g] of PLANNED) {
603 const e = ent(0, 20, 30, { food, g, meal: 'Dinner' }, 'pick');
604 e.p = 1;
605 diet.push(e);
606 plannedKcal += KCAL[food] * g / 100;
607}
608// On the empty day, so that a plan cannot extend a streak or raise a bar out of nothing.
609const gapPlan = ent(GAP_DAY, 12, 0, { food: 'app', g: 150, meal: 'Lunch' }, 'pick');
610gapPlan.p = 1;
611diet.push(gapPlan);
612const STREAK = GAP_DAY; // days 0..4 have something real in them, and the fifth has a plan
613
614// Distinct foods eaten today, which is what the pie has to split up. Eleven of them, and the
615// page's rule is that a ninth series is never a ninth hue: seven, then a neutral Other. Real
616// entries only: the pie is a reading and a plan is not read.
617const TODAY_FOODS = new Set(diet.filter(e => e.day === ymd(new Date()) && !e.p).map(e => e.f.food));
618const DAYS_LOGGED = 13; // fourteen buckets on the chart, one of them empty
619
620// Yesterday's session is the one the verifier opens, so its numbers are named here.
621const PUSH = [['bp', 85, 5], ['bp', 85, 4], ['ohp', 47.5, 6], ['push', 25, 15], ['dip', 0, 10]];
622const PLAN = [
623 [12, 'Legs', [['sq', 100, 5], ['sq', 100, 5], ['sq', 105, 3], ['rdl', 80, 8], ['rdl', 80, 8], ['lp', 160, 10]]],
624 [9, 'Push', [['bp', 80, 5], ['bp', 82.5, 5], ['bp', 85, 3], ['ohp', 45, 8], ['ohp', 45, 7], ['dip', 0, 12]]],
625 [6, 'Pull', [['dl', 140, 3], ['dl', 145, 3], ['row', 70, 8], ['row', 70, 8], ['lat', 60, 10], ['curl', 14, 12]]],
626 [3, 'Legs', [['sq', 105, 5], ['sq', 105, 5], ['sq', 110, 3], ['rdl', 85, 8], ['lp', 170, 10]]],
627 [1, 'Push', PUSH],
628];
629const SESS_MIN = 62;
630for (const [back, name, sets] of PLAN) {
631 const s = ent(back, 18, 0, { name }, 'tap');
632 // TWO STAMPS AND NO TYPED DURATION. A session is open until it has an `end`, and its
633 // length is the distance between the two — so every one of these is finished, none of
634 // them scopes the page on opening, and the 1h 2m the screen shows for one of them is
635 // arithmetic on the pair rather than a number in a field.
636 s.sess = 1;
637 s.end = isoAt(at(back, 19, 2));
638 gym.push(s);
639 sets.forEach(([lift, kg, reps], i) => {
640 gym.push(ent(back, 18, 4 + i * 6, { lift, kg, reps }, 'form', s.id));
641 });
642}
643// How many of those sessions fall in the month the run happens in — COUNTED rather than assumed.
644// `back: 12` is the second of the month on the fourteenth and the twenty-fourth of the month
645// before on the sixth, so a check that assumed all five would be a check that failed for the
646// first fortnight of every month.
647const THIS_MONTH = ymd(new Date()).slice(0, 7);
648const SESS_IN_MONTH = PLAN.filter(([back]) => ymd(at(back, 18, 0)).slice(0, 7) === THIS_MONTH).length;
649
650// Volume is `kg*reps` summed, and the sets are grouped by lift in the order each first appeared.
651const PUSH_LIFTS = [...new Set(PUSH.map(x => x[0]))];
652const BENCH_VOL = PUSH.filter(x => x[0] === 'bp').reduce((a, [, kg, reps]) => a + kg * reps, 0);
653
654const shard = es => {
655 const by = {};
656 for (const e of es) (by[e.day.slice(0, 7)] ||= []).push(JSON.stringify(e));
657 return by;
658};
659// LOCAL, not `toISOString`: the page files an entry under the local day it belongs to, and a
660// UTC month would read the wrong shard for anybody east of Greenwich in the first hours of a
661// month. That is the same trap the page's own date comment names.
662const ym = ymd(new Date()).slice(0, 7);
663const today = ymd(new Date());
664
665// ── Drive it ──────────────────────────────────────────────────────
666const s = await open({ name: 'lifelog', signIn: false, connect: false });
667const { page } = s;
668const shot = async (label) => {
669 const p = path.join(SHOTS, label + '.png');
670 await page.screenshot({ path: p, fullPage: false, timeout: 12000 }).catch(e => console.log('shot: ' + e));
671 console.log(' shot ' + p);
672};
673/// The crystal column alone, which is the thing being judged. The frame is taller than the
674/// panel, so the panel is scrolled first and the shot is of what a reader would have in front
675/// of them.
676const shotP = async (label, y) => {
677 if (y != null) {
678 // Whatever actually scrolls. `.crystal-body` is the scroller on the desktop layout and is
679 // NOT one on the phone, where the panel is a sheet — so a run that only ever set
680 // `.crystal-body.scrollTop` took every 375px shot at the top of the page and labelled them
681 // "charts" and "session". A shot that does not show what its name says is worse than none.
682 const moved = await page.evaluate((yy) => {
683 const seen = [];
684 let el = document.querySelector('.crystal-frame');
685 while (el && el !== document.documentElement) {
686 const cs = getComputedStyle(el);
687 if (el.scrollHeight > el.clientHeight + 4 && /auto|scroll/.test(cs.overflowY)) {
688 el.scrollTop = yy;
689 seen.push((el.className || el.tagName) + '=' + el.scrollTop);
690 if (el.scrollTop > 0) return seen;
691 }
692 el = el.parentElement;
693 }
694 window.scrollTo(0, yy);
695 seen.push('window=' + window.scrollY);
696 return seen;
697 }, y);
698 if (!moved.some(x => !/=0$/.test(x))) console.log(' (nothing scrolled: ' + moved.join(' ') + ')');
699 await page.waitForTimeout(350);
700 }
701 const el = await page.$('.panel[data-panel="ai"]');
702 const p = path.join(SHOTS, label + '.png');
703 if (el) await el.screenshot({ path: p, timeout: 12000 }).catch(e => console.log('shot: ' + e));
704 else await page.screenshot({ path: p, timeout: 12000 }).catch(() => {});
705 console.log(' shot ' + p);
706};
707/// What the channel thinks of the page: the reason it gave up, if it did, and the keys the page
708/// said it drew. `_state()` is the verifier's window on it and the app never uses it.
709const state = () => page.evaluate(() => {
710 const st = window.DaimondCrystal && window.DaimondCrystal._state && window.DaimondCrystal._state();
711 return st ? { mode: st.mode, reason: st.reason, keys: st.keys, ready: st.ready } : null;
712});
713/// Anything inside the crystal frame. It is the only blob: frame on the page — the guide is a
714/// child of the main frame too, and a naive search finds that one and measures it instead.
715const inFrame = async (fn, arg) => {
716 const f = page.frames().find(fr => fr.url().indexOf('blob:') === 0);
717 if (!f) throw new Error('the crystal frame is not mounted; channel says '
718 + JSON.stringify(await state()));
719 return await f.evaluate(fn, arg);
720};
721/// A number as the page printed it: "1,980" and "82.4 kg" both come back as numbers.
722const numOf = (txt) => {
723 const m = /-?[\d,]*\.?\d+/.exec(String(txt || '').replace(/\s/g, ''));
724 return m ? Number(m[0].replace(/,/g, '')) : NaN;
725};
726
727let id = '';
728/// One lane's shard as records, read outside the page through the engine. `file_read` numbers
729/// its lines, so the prefix comes off before the JSON does.
730const shardLines = async (lane = 'diet') => {
731 const txt = await page.evaluate((a) => window.__free
732 .run_tool('file_read', JSON.stringify({ path: 'diamonds/' + a.id + '/log/' + a.lane + '/' + a.ym + '.jsonl' }))
733 .then(String).catch(e => 'ERR ' + e), { id, ym, lane });
734 return String(txt).split('\n')
735 .map(x => x.replace(/^\s*\d+\t/, ''))
736 .filter(x => x.trim())
737 .map(x => { try { return JSON.parse(x); } catch { return null; } })
738 .filter(Boolean);
739};
740/// A lane file as an object, whatever the engine put in front of its lines — or null, which is
741/// what a lane that was never written looks like from out here.
742const jsonAt = async (rel) => {
743 const txt = await page.evaluate((a) => window.__free
744 .run_tool('file_read', JSON.stringify({ path: 'diamonds/' + a.id + '/' + a.rel }))
745 .then(String).catch(e => 'ERR ' + e), { id, rel });
746 const body = String(txt).split('\n').map(x => x.replace(/^\s*\d+\t/, '')).join('\n').trim();
747 try { return JSON.parse(body); } catch { return null; }
748};
749/// Everything a lane points at that is not there. The same invariant the static block holds the
750/// SHIPPED lanes to, applied to a lane the page made a minute ago: a `show` naming a field that
751/// does not exist draws a dash for ever, and a tile naming one logs nothing when it is pressed.
752const laneComplaints = (l) => {
753 const byK = new Map(((l && l.fields) || []).map(f => [f.k, f])), out = [];
754 const wants = (k, why) => { if (k && !byK.has(k)) out.push(why + ' names `' + k + '`'); };
755 wants(l.primary, 'primary');
756 (l.show || []).forEach(s => wants(String(s).split(':')[0], 'show'));
757 (l.targets || []).forEach(t => wants(t.f, 'a target'));
758 for (const t of l.tiles || []) {
759 wants(t.by, 'tile `' + t.n + '`');
760 (t.fs || []).forEach(k => wants(k, 'tile `' + t.n + '`'));
761 for (const bag of [t.f].concat(t.e || []).filter(Boolean)) {
762 for (const k of Object.keys(bag)) {
763 wants(k, 'tile `' + t.n + '`');
764 const fd = byK.get(k);
765 // A tile that sets a choice the field does not offer records a value nothing can
766 // read back — the same complaint the static block makes about the shipped lanes.
767 if (fd && fd.t === 'enum' && !(fd.o || []).includes(bag[k])) {
768 out.push('tile `' + t.n + '` sets ' + k + '=`' + bag[k] + '`, not one of its choices');
769 }
770 }
771 }
772 }
773 return out;
774};
775/// The view the page is in, read off the switch itself rather than guessed from what is drawn.
776const viewNow = () => inFrame(() => {
777 const on = document.querySelector('[data-a="view"].on');
778 return on ? on.getAttribute('data-v') : '';
779});
780/// What each view is made of, which is the whole of the owner's second request.
781const viewParts = () => inFrame(() => ({
782 tiles: document.querySelectorAll('.pad .tile').length,
783 rows: document.querySelectorAll('.er').length,
784 stats: document.querySelectorAll('.st').length,
785 charts: document.querySelectorAll('svg').length,
786}));
787/// mm:ss as seconds, or -1. The rest clock is read off the screen, because what the page holds
788/// in a closure is not what the person in the gym is looking at.
789const clockSecs = (t) => {
790 const m = /(\d+):(\d+)/.exec(String(t || ''));
791 return m ? Number(m[1]) * 60 + Number(m[2]) : -1;
792};
793/// The live list, group by group: what each exercise is called, what its heading adds up to,
794/// what each of its rows is showing, and which rows are still only a plan.
795const liveGroups = () => inFrame(() => [...document.querySelectorAll('.grp')].map(g => ({
796 n: g.querySelector('.grph .gap').textContent,
797 vol: g.querySelector('.grph .ev').textContent,
798 pend: g.querySelectorAll('.setr.pend').length,
799 green: g.querySelectorAll('.tick.on').length,
800 vals: [...g.querySelectorAll('.setr')].map(r => {
801 const ins = [...r.querySelectorAll('.setin')];
802 return ins.length ? ins.map(i => i.value).join('x') : r.querySelector('.gap').textContent.trim();
803 }),
804})));
805/// THE ROOM A GLYPH ACTUALLY HAS, which is not the same question as whether the text is there.
806///
807/// An input whose `value` is correct and whose content box is a fraction of a pixel shorter
808/// than its own type shows nothing at all, and every assertion on its value passes. So each
809/// control is measured three ways: the height left inside the padding against the font size,
810/// and whether the content overflows the box in either direction. All of the new controls are
811/// small boxes with numbers in them, which is precisely the shape this fails on.
812const roomIn = (sel) => inFrame((s) => [...document.querySelectorAll(s)].map((el) => {
813 const cs = getComputedStyle(el);
814 const r1 = (v) => Math.round(v * 10) / 10;
815 return {
816 n: s + '>' + String(el.className || el.tagName).split(' ').join('.'),
817 fs: r1(parseFloat(cs.fontSize)),
818 room: r1(el.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)),
819 overW: r1(el.scrollWidth - el.clientWidth),
820 overH: r1(el.scrollHeight - el.clientHeight),
821 };
822}), sel);
823/// THE CONTRAST OF INK ON THE GROUND IT ACTUALLY SITS ON.
824///
825/// The walk up for the first OPAQUE background is the whole of this function, and it is here
826/// because of a real failure in this app: a contrast verifier once reported eleven palettes at
827/// exactly 1.00 because it read `background-color` off the element, got `rgba(0,0,0,0)`,
828/// treated that as a colour, and was in effect photographing the backdrop. So a colour that is
829/// not opaque is not a ground, and an element whose ground cannot be resolved is REPORTED with
830/// a null ratio rather than quietly scored as anything at all.
831///
832/// Only elements with a text node of their OWN are measured. A container is not judged on text
833/// belonging to its children, or a wrapper whose own colour nothing ever paints would fail a
834/// check about something a reader can actually see.
835const contrastIn = (sel) => inFrame((s) => {
836 const lum = (c) => {
837 const m = /(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?/.exec(c || '');
838 if (!m) return null;
839 if (m[4] != null && Number(m[4]) < 0.95) return null;
840 const f = (v) => {
841 v = Number(v) / 255;
842 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
843 };
844 return 0.2126 * f(m[1]) + 0.7152 * f(m[2]) + 0.0722 * f(m[3]);
845 };
846 const ground = (el) => {
847 let e = el;
848 while (e) {
849 const g = lum(getComputedStyle(e).backgroundColor);
850 if (g != null) return g;
851 e = e.parentElement;
852 }
853 return null;
854 };
855 const own = (k) => [...k.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
856 const out = [];
857 for (const el of document.querySelectorAll(s)) {
858 for (const k of [el, ...el.querySelectorAll('*')]) {
859 if (!own(k)) continue;
860 const ink = lum(getComputedStyle(k).color), bg = ground(k);
861 const name = s + '>' + String(k.className || k.tagName).split(' ').join('.');
862 if (ink == null || bg == null) { out.push({ n: name, r: null }); continue; }
863 const hi = Math.max(ink, bg) + 0.05, lo = Math.min(ink, bg) + 0.05;
864 out.push({ n: name, r: Math.round((hi / lo) * 100) / 100 });
865 }
866 }
867 return out;
868}, sel);
869
870try {
871 await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' });
872 await signInAs(s, 'lifelog');
873 await connectMock(s);
874 await page.waitForTimeout(1500);
875
876 await page.click('#new-diamond-btn', { force: true });
877 await page.waitForSelector('.dlg-input', { timeout: 10000 });
878 await page.fill('.dlg-input', 'Lifelog');
879 await page.click('.dlg-ok', { force: true });
880 await page.waitForTimeout(1800);
881 await page.$$eval('.diamond-box', els => els[0] && els[0].click());
882 await page.waitForTimeout(1200);
883
884 id = await page.evaluate(async () => {
885 const m = await import('/pkg/oxedyne_daimond.js');
886 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
887 window.__free = app;
888 const d = JSON.parse(await app.list_diamonds()).find(x => x.name === 'Lifelog');
889 return d ? d.id : '';
890 });
891 check('a Diamond to live in', !!id, id);
892
893 // `habits` is the one that matters: a key no page has heard of, which is the EXPECTED case
894 // because the reducer is a fresh model rewriting the whole crystal from one sentence. A page
895 // that drew the four it recognised and left this one out must be taken down.
896 const CRYSTAL = {
897 title: 'Lifelog',
898 summary: 'What I eat, what I lift, and what the scales say. Kept here so the '
899 + 'numbers are mine and stay on this device.',
900 facts: [{ k: 'Started', v: 'this month' }, { k: 'Lanes', v: 'diet, gym, body' }],
901 open: ['Get fibre over 30 g a day', 'Squat 120 kg for five'],
902 habits: { weighIn: 'every morning, before breakfast' },
903 };
904 // The template laid down exactly as `cappFiles` lays it down, unless `--seed`, in which case
905 // the page arrives alone and has to furnish the Diamond itself.
906 const carried = SEED_MODE ? [] : CARRIES.map(rel => [rel, FILE(rel)]);
907 await page.evaluate(async (a) => {
908 const w = (p, c) => window.__free.run_tool('file_write', JSON.stringify({ path: p, content: c }));
909 await w('diamonds/' + a.id + '/crystal.json', a.crystal);
910 await w('diamonds/' + a.id + '/crystal.html', a.page);
911 for (const [rel, text] of a.carried) await w('diamonds/' + a.id + '/' + rel, text);
912 for (const [lane, by] of a.logs) {
913 for (const ymk in by) {
914 await w('diamonds/' + a.id + '/log/' + lane + '/' + ymk + '.jsonl', by[ymk].join('\n') + '\n');
915 }
916 }
917 }, {
918 id, page: PAGE, crystal: JSON.stringify(CRYSTAL), carried,
919 logs: [['diet', shard(diet)], ['gym', shard(gym)], ['body', shard(body)]],
920 });
921
922 // Re-select, so the crystal renders AFTER the page was written — and again after every
923 // viewport change. A resize reparents the panel, the iframe fires a SECOND `load`, and
924 // crystal.js treats that as `partial` and tears the frame down. That is the app's rule and
925 // not this page's business, but it means a screenshot at a new width has to be taken on a
926 // fresh mount.
927 const mount = async (w, h) => {
928 if (w) { await page.setViewportSize({ width: w, height: h }); await page.waitForTimeout(700); }
929 // The guide and the workspace take two of four seats; a person reading a Diamond has them
930 // shut, and with them open the crystal column is 250px.
931 await page.evaluate(() => { DaimondPanels.hide('guide'); DaimondPanels.hide('work'); });
932 await page.waitForTimeout(400);
933 await page.evaluate(() => DaimondPanels.show('ai'));
934 await page.waitForTimeout(400);
935 await page.$$eval('.diamond-box', els => els[0] && els[0].click());
936 await page.waitForTimeout(3200);
937 };
938 // Tall enough that the whole crystal column is in the shot. The panel is the scroller and it
939 // does not answer to `scrollTop` from a script — see `shotP` — so the only honest way to
940 // photograph the bottom of the page is to give the viewport room for it.
941 await mount(1280, 1560);
942
943 // ── 2. The coverage trap, checked from both ends ──
944 const up = await page.evaluate(() => !!document.querySelector('#crystal-frame, .crystal-frame'));
945 const fell = await page.evaluate(() => !!document.querySelector('.crystal-fallback-note'));
946 check('THE FRAME IS UP AND THE APP DID NOT FALL BACK', up && !fell,
947 'frame:' + up + ' fallback:' + fell);
948 // THE PAGE THAT IS ACTUALLY RUNNING, read out of the frame's own document.
949 //
950 // A break lives in a string held by this file; what matters is the code the browser is
951 // executing. If the damage never reached that, the run goes green for the best of reasons
952 // and proves nothing — and the matrix then reads exactly like a matrix of real results.
953 // So on a clean run the document is confirmed whole, and on a broken one the anchor is
954 // confirmed GONE and the damage confirmed PRESENT: both directions, because a replacement
955 // that appended rather than replaced would satisfy only the second.
956 //
957 // It is read from the FRAME and not from the file, and that is not a convenience. The
958 // engine's `file_read` truncates at 80,000 bytes and the page is larger than that, so a
959 // check written against the file would have reported every late anchor as gone — the page
960 // literal that seeds the lanes is in the last tenth of it. `outerHTML` carries the text of
961 // every `<style>` and `<script>` verbatim, which is where every anchor here lives.
962 // One break legitimately KEEPS its anchor and wraps a guard round it, so "the anchor is
963 // gone" is only asked of a replacement that replaces. Distinguishing the two is the point:
964 // the check this first ran under called that break broken, which is the same instrument
965 // working — it noticed a difference between what was meant and what was there.
966 const spec = BREAK ? BREAKS[BREAK] : null;
967 const additive = !!spec && spec.with.indexOf(spec.find) >= 0;
968 const running = await inFrame(() => document.documentElement.outerHTML);
969 check(BREAK ? 'AND THE BREAK REACHED THE PAGE THAT IS RUNNING'
970 : 'the page that is running is the page in this repo',
971 BREAK
972 ? running.includes(spec.with) && (additive || !running.includes(spec.find))
973 : running.includes(PAGE.slice(PAGE.indexOf('<style>'), PAGE.indexOf('<style>') + 400))
974 && running.length > bytes * 0.9,
975 BREAK
976 ? 'damage present: ' + running.includes(spec.with)
977 + (additive ? ' (additive, so the anchor stays)'
978 : ', anchor gone: ' + !running.includes(spec.find))
979 : running.length + ' characters running vs ' + bytes + ' bytes on disk');
980
981 const st = await state();
982 const missing = Object.keys(CRYSTAL).filter(k => !(st && (st.keys || []).includes(k)));
983 check('and `rendered` named every content key, the unknown one included',
984 missing.length === 0, 'missing: ' + (missing.join(', ') || 'none')
985 + ' | mode:' + (st && st.mode) + ' reason:' + (st && st.reason));
986
987 // ── 3. The Diamond is furnished, by whichever of the two routes applies ──
988 //
989 // `seeded` is the tell: the page stamps `index.json` with the moment it furnished the store,
990 // and the shipped template has no such stamp. So the delivered run proves the page READ the
991 // template rather than overwriting it, and the seed run proves the page wrote one when it
992 // found nothing — which the same string test could never have separated.
993 const readAt = (rel) => page.evaluate((a) => window.__free
994 .run_tool('file_read', JSON.stringify({ path: 'diamonds/' + a.id + '/' + a.rel }))
995 .then(String).catch(e => 'ERR ' + e), { id, rel });
996 const laneFile = await readAt('lanes/diet.json');
997 const indexFile = await readAt('index.json');
998 const stamped = /"seeded"/.test(indexFile);
999 check('it has furnished lanes to be used from',
1000 /"tiles"/.test(laneFile) && /Porridge/.test(laneFile), laneFile.slice(0, 70));
1001 check(SEED_MODE
1002 ? 'and it furnished them ITSELF, having arrived with nothing'
1003 : 'and it read the ones the template carried rather than seeding over them',
1004 SEED_MODE ? stamped : !stamped, 'seeded stamp: ' + stamped);
1005
1006 // It drew the seeded fortnight rather than an empty day: one row per top-level entry today.
1007 const todayRows = diet.filter(e => e.day === today).length;
1008 const drew = await inFrame(() => ({
1009 tiles: document.querySelectorAll('.tile').length,
1010 rows: document.querySelectorAll('.er').length,
1011 names: [...document.querySelectorAll('.er .en')].map(x => x.firstChild.textContent),
1012 wide: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
1013 }));
1014 check('every one of today\'s entries is a row, named from the catalogue',
1015 drew.rows === todayRows && drew.names.includes('Rolled oats') && drew.names.includes('Black coffee'),
1016 drew.rows + ' rows for ' + todayRows + ' entries; ' + drew.names.slice(0, 3).join(' / '));
1017 check('no horizontal scroll', !drew.wide, JSON.stringify({ wide: drew.wide }));
1018
1019 // ── `p`: a plan, drawn ──
1020 //
1021 // The row count above already includes the two planned meals, because a plan is an entry
1022 // like any other and IS shown. This is the other half: that it is shown AS a plan, so a
1023 // day already eaten is not confused with a day merely intended. It needs its own break,
1024 // because every aggregation check below is equally green whether a plan is uncounted or
1025 // simply absent — two correct checks with a gap between them that neither can see.
1026 const planned = await inFrame(() => ({
1027 rows: document.querySelectorAll('.er').length,
1028 pend: document.querySelectorAll('.er.pend').length,
1029 said: [...document.querySelectorAll('.er.pend small')].map(x => x.textContent).join(' | '),
1030 names: [...document.querySelectorAll('.er.pend .en')].map(x => x.firstChild.textContent),
1031 }));
1032 check('A PLANNED ENTRY IS DRAWN, AND DRAWN AS A PLAN',
1033 planned.pend === PLANNED.length && planned.rows === todayRows
1034 && planned.names.includes('Chicken breast') && /planned/.test(planned.said),
1035 JSON.stringify(planned));
1036
1037 // ── 9. Log and Life: the axis he asked for ──
1038 //
1039 // Assert what each view IS MADE OF, not which button looks pressed. The complaint was that
1040 // one screen mixed entry with reporting, so the property is that the tile pad and the charts
1041 // are never on the same screen — and that a person who presses Life can get back.
1042 const logParts = await viewParts();
1043 check('LOG IS THE ENTRY VIEW: tiles to press and the entries they made, nothing to read back',
1044 logParts.tiles > 0 && logParts.rows > 0 && logParts.stats === 0 && logParts.charts === 0,
1045 JSON.stringify(logParts));
1046 await inFrame(() => document.querySelector('[data-a="view"][data-v="life"]').click());
1047 await page.waitForTimeout(900);
1048 const lifeParts = await viewParts();
1049 check('LIFE IS THE READING VIEW: the numbers and the charts, and no tile pad in front of them',
1050 lifeParts.stats > 0 && lifeParts.charts > 0 && lifeParts.tiles === 0,
1051 JSON.stringify(lifeParts));
1052
1053 // ── 7a. The arithmetic, against a number computed here ──
1054 const energy = await inFrame(() => {
1055 const st2 = [...document.querySelectorAll('.st')]
1056 .find(x => /Energy/.test(x.querySelector('.sk').textContent));
1057 return st2 ? st2.querySelector('.sv').textContent : '';
1058 });
1059 check('TODAY\'S ENERGY IS THE FIXTURE\'S OWN ARITHMETIC',
1060 numOf(energy) === Math.round(todayKcal),
1061 'page: ' + JSON.stringify(energy) + ' expected: ' + Math.round(todayKcal)
1062 + ' (' + todayKcal.toFixed(1) + ')');
1063 await shotP('lifelog-1280-dark-life', 0);
1064 await inFrame(() => document.querySelector('[data-a="view"][data-v="log"]').click());
1065 await page.waitForTimeout(800);
1066 const backParts = await viewParts();
1067 const backView = await viewNow();
1068 check('AND THE SWITCH GOES BOTH WAYS, so a tap on Life is not a mode to be stuck in',
1069 backView === 'log' && backParts.tiles > 0 && backParts.charts === 0,
1070 'view:' + backView + ' ' + JSON.stringify(backParts));
1071
1072 console.log(' channel: ' + JSON.stringify(st));
1073 // The frame is only as tall as the page says it is, and a page that reports short is a page
1074 // whose bottom half nobody ever sees.
1075 const geom = await page.evaluate(() => {
1076 const f = document.querySelector('.crystal-frame');
1077 return f ? { h: f.clientHeight, min: f.style.minHeight } : null;
1078 });
1079 const inner = await inFrame(() => ({
1080 body: document.body.scrollHeight, root: document.documentElement.scrollHeight,
1081 app: document.getElementById('app').scrollHeight,
1082 }));
1083 check('the frame is as tall as the page', geom && geom.h >= inner.app - 8,
1084 JSON.stringify(geom) + ' vs ' + JSON.stringify(inner));
1085 // AND THE HEIGHT CAME FROM THE PAGE, which `clientHeight` alone cannot tell you: at this
1086 // viewport the frame's own layout is already taller than the page, so the check above is
1087 // green whether the page reported a height or never opened its mouth. `--break noheight`
1088 // drops the `height` message and that check did not move — a break proving nothing.
1089 // `crystal.js:onHeight` turns the message into an inline `min-height` (a MINIMUM, so a
1090 // short page still fills the panel), and that inline style is the only evidence that the
1091 // page's own number reached the app. On a phone, where the panel is shorter than the page,
1092 // it is also the whole reason the bottom half is reachable at all.
1093 const minPx = geom && parseFloat(geom.min || '');
1094 check('AND ITS HEIGHT CAME FROM THE PAGE\'S OWN MESSAGE, not from the layout around it',
1095 !!(minPx > 0 && minPx >= inner.app - 8),
1096 'min-height:' + JSON.stringify(geom && geom.min) + ' vs the page\'s ' + inner.app + 'px');
1097 await shotP('lifelog-1280-dark-log', 0);
1098 await shotP('lifelog-1280-dark-log-2', 620);
1099
1100 // ── 8. Charts ──
1101 await inFrame(() => document.querySelector('[data-a="view"][data-v="life"]').click());
1102 await page.waitForTimeout(700);
1103 await shotP('lifelog-1280-dark-charts', 560);
1104 const chart = await inFrame(() => ({
1105 bars: document.querySelectorAll('svg path[fill="var(--ac)"]').length,
1106 legend: [...document.querySelectorAll('.leg .lgn')].map(x => x.textContent),
1107 }));
1108 check('A BAR PER DAY THAT HAS SOMETHING, AND NONE FOR THE DAY THAT HAS NOT',
1109 chart.bars === DAYS_LOGGED, chart.bars + ' bars over 14 buckets, ' + DAYS_LOGGED + ' logged');
1110 // ── `p`: and counted by nothing ──
1111 //
1112 // The energy stat above is one witness. These are the other three, and they are here
1113 // rather than folded into it because they are three separate code paths: the pie goes
1114 // through `groups`, the streak through `streak`, and the bar for the empty day through
1115 // `aggPer`. A gate that leaked into any one of them would leave a number on the screen
1116 // that nobody has earned. The bar count is the check immediately above this one.
1117 const noCount = await inFrame(() => ({
1118 streak: (document.querySelector('.tag b') || {}).textContent || '',
1119 pie: (document.querySelector('.pie svg text') || {}).textContent || '',
1120 }));
1121 check('AND NO NUMBER COUNTS IT: NOT THE PIE, NOT THE STREAK, NOT THE EMPTY DAY\'S BAR',
1122 Number(noCount.streak) === STREAK && numOf(noCount.pie) === Math.round(todayKcal)
1123 && Math.round(todayKcal) !== Math.round(todayKcal + plannedKcal),
1124 JSON.stringify(noCount) + ' expected streak ' + STREAK + ', pie ' + Math.round(todayKcal)
1125 + ' (a plan would have made it ' + Math.round(todayKcal + plannedKcal) + ')');
1126
1127 check('THE PIE FOLDS ITS TAIL INTO ONE NEUTRAL OTHER',
1128 TODAY_FOODS.size > 8 && chart.legend.length === 8
1129 && chart.legend[chart.legend.length - 1] === 'Other',
1130 TODAY_FOODS.size + ' foods -> ' + chart.legend.length + ' series: ' + chart.legend.join(', '));
1131
1132 // Group the pie by the catalogue's own attribute — the food-group split. Same mechanism as
1133 // grouping by the ref field itself, one option along.
1134 await inFrame(() => document.querySelectorAll('[data-a="pieg"]')[1].click());
1135 await page.waitForTimeout(500);
1136 const byGroup = await inFrame(() => [...document.querySelectorAll('.leg .lgn')].map(x => x.textContent));
1137 check('and it groups by an attribute of the catalogue item, not only by the field',
1138 byGroup.includes('Grain') && byGroup.includes('Protein') && byGroup.length < 8,
1139 byGroup.join(', '));
1140 await shotP('lifelog-1280-dark-pie-bygroup', 900);
1141
1142 // Week, then month.
1143 await inFrame(() => document.querySelector('[data-a="per"][data-v="week"]').click());
1144 await page.waitForTimeout(900);
1145 await shotP('lifelog-1280-dark-week', 560);
1146 await inFrame(() => document.querySelector('[data-a="per"][data-v="month"]').click());
1147 await page.waitForTimeout(1200);
1148 await shotP('lifelog-1280-dark-month', 560);
1149 await inFrame(() => document.querySelector('[data-a="per"][data-v="day"]').click());
1150 await page.waitForTimeout(600);
1151
1152 // ── 7b. The gym lane, one day back — where yesterday's session is ──
1153 await inFrame(() => document.querySelector('[data-a="lane"][data-v="gym"]').click());
1154 await page.waitForTimeout(1000);
1155 await inFrame(() => document.querySelector('[data-a="view"][data-v="log"]').click());
1156 await page.waitForTimeout(400);
1157 await inFrame(() => document.querySelector('[data-a="off"][data-v="-1"]').click());
1158 await page.waitForTimeout(1000);
1159 await shotP('lifelog-1280-dark-gym', 0);
1160 const opened = await inFrame(() => {
1161 const r = document.querySelector('[data-a="sess"]');
1162 if (!r) return false;
1163 r.click(); return true;
1164 });
1165 await page.waitForTimeout(600);
1166 await shotP('lifelog-1280-dark-session', 0);
1167 await shotP('lifelog-1280-dark-session-2', 520);
1168 const sess = await inFrame(() => ({
1169 groups: [...document.querySelectorAll('.grph')].map(g => ({
1170 n: g.querySelector('.gap').textContent, v: g.querySelector('.ev').textContent,
1171 })),
1172 sets: document.querySelectorAll('.setr').length,
1173 }));
1174 const bench = sess.groups.find(g => g.n === 'Bench press');
1175 check('A SESSION OPENS ON ITS SETS, GROUPED BY LIFT IN THE ORDER THEY APPEARED',
1176 opened && sess.sets === PUSH.length && sess.groups.length === PUSH_LIFTS.length,
1177 sess.sets + ' sets in ' + sess.groups.length + ' groups; expected '
1178 + PUSH.length + ' in ' + PUSH_LIFTS.length);
1179 check('AND ONE LIFT\'S VOLUME IS kg x reps SUMMED, NOT A NUMBER OFF A SCREEN',
1180 !!bench && numOf(bench.v) === BENCH_VOL,
1181 'page: ' + JSON.stringify(bench && bench.v) + ' expected: ' + BENCH_VOL + ' kg');
1182
1183 // ── `end`: a length that falls out of two stamps ──
1184 // The fixture typed no duration into any of these sessions. It stamped a start and a
1185 // finish 62 minutes apart, and the number on the scope bar is the distance between them.
1186 const spanBar = await inFrame(() => ({
1187 ts: (document.querySelector('.scope .ts') || {}).textContent || '',
1188 fin: (document.querySelector('[data-a="finish"]') || {}).textContent || '',
1189 }));
1190 check('A CLOSED SESSION\'S LENGTH IS THE DISTANCE BETWEEN ITS TWO STAMPS',
1191 /\b1h 2m\b/.test(spanBar.ts) && spanBar.fin === 'Reopen',
1192 JSON.stringify(spanBar) + ' expected ' + SESS_MIN + ' minutes and a way to reopen');
1193
1194 // ── THE WORKOUT ──────────────────────────────────────────────────
1195 //
1196 // What the owner asked for, and what he did NOT ask for: a Gym screen. What is built is
1197 // the Log view given three things it did not have — a SCOPE (only this session's own
1198 // entries), a GROUPING (by the lane's `primary`), and rows that are a PLAN until they are
1199 // ticked. Everything asserted below is asserted through those three, which is why the
1200 // checks read `.grp`, `.setr` and `.scope` rather than anything named after a gym.
1201 await inFrame(() => {
1202 const b = document.querySelector('[data-a="unscope"]');
1203 if (b) b.click();
1204 });
1205 await page.waitForTimeout(400);
1206 await inFrame(() => document.querySelector('[data-a="now"]').click());
1207 await page.waitForTimeout(700);
1208
1209 // The audio instrument, installed BEFORE anything can open a context — and proved to read
1210 // nothing first. A counter that was already at one would report the page as correct
1211 // whatever the page went on to do, which is the failure this project keeps paying for.
1212 await inFrame(() => {
1213 const Real = window.AudioContext || window.webkitAudioContext;
1214 window.__audio = { have: !!Real, ctx: 0, osc: 0, when: [] };
1215 if (!Real) return;
1216 const Fake = function () {
1217 const c = new Real();
1218 window.__audio.ctx++;
1219 const co = c.createOscillator.bind(c);
1220 c.createOscillator = function () {
1221 const o = co();
1222 window.__audio.osc++;
1223 const st = o.start.bind(o);
1224 o.start = function (t) {
1225 window.__audio.when.push(Math.round(t - c.currentTime));
1226 return st(t);
1227 };
1228 return o;
1229 };
1230 return c;
1231 };
1232 window.AudioContext = Fake;
1233 window.webkitAudioContext = Fake;
1234 });
1235 const audio0 = await inFrame(() => window.__audio);
1236 check('the audio instrument is in place and reads nothing yet',
1237 audio0.have && audio0.ctx === 0 && audio0.osc === 0, JSON.stringify(audio0));
1238
1239 // ── A button starts a session, and the session becomes the screen ──
1240 const gymWas = new Set((await shardLines('gym')).map(e => e.id));
1241 await inFrame(() => {
1242 const t = [...document.querySelectorAll('.pad .tile')]
1243 .find(x => /Start a workout/.test(x.textContent));
1244 if (t) t.click();
1245 });
1246 await page.waitForTimeout(1600);
1247 const startLines = (await shardLines('gym')).filter(e => !gymWas.has(e.id));
1248 const sessId = startLines.length ? startLines[0].id : '';
1249 const live1 = await inFrame(() => ({
1250 scope: !!document.querySelector('.scope'),
1251 title: (document.querySelector('.scope .h1') || {}).textContent || '',
1252 finish: (document.querySelector('[data-a="finish"]') || {}).textContent || '',
1253 dates: !!document.querySelector('[data-a="off"]'),
1254 tiles: [...document.querySelectorAll('.pad .tile .tn')].map(x => x.textContent),
1255 }));
1256 await shotP('lifelog-1280-dark-workout-start', 0);
1257 check('A WORKOUT STARTS OPEN, TITLED, AND BECOMES THE SCREEN',
1258 startLines.length === 1 && startLines[0].sess === 1 && !startLines[0].end
1259 && live1.scope && live1.title === 'Morning' && live1.finish === 'Finish'
1260 && !live1.dates && live1.tiles.includes('Add exercises')
1261 && !live1.tiles.includes('Start a workout'),
1262 JSON.stringify(live1) + ' | disk: '
1263 + JSON.stringify(startLines.map(e => ({ sess: e.sess, end: e.end, f: e.f }))));
1264
1265 // ── A picker that takes several, and the contrast of what it has taken ──
1266 //
1267 // The PRESSED state cannot be read off a computed style: `:active` needs a button that is
1268 // actually held down. So it is held down, on the frame's own coordinates, measured while
1269 // down, and then released — and the release IS the click that opens the picker, so the
1270 // measurement costs no extra interaction and cannot drift out of the sequence.
1271 let pressed = [];
1272 const tileBox = await (async () => {
1273 const f = page.frames().find(fr => fr.url().indexOf('blob:') === 0);
1274 return f ? await f.locator('.tile.acc').first().boundingBox().catch(() => null) : null;
1275 })();
1276 if (tileBox) {
1277 await page.mouse.move(tileBox.x + tileBox.width / 2, tileBox.y + tileBox.height / 2);
1278 await page.mouse.down();
1279 await page.waitForTimeout(180);
1280 pressed = await contrastIn('.tile:active');
1281 await page.mouse.up();
1282 } else {
1283 await inFrame(() => {
1284 const t = [...document.querySelectorAll('.pad .tile')]
1285 .find(x => /Add exercises/.test(x.textContent));
1286 if (t) t.click();
1287 });
1288 }
1289 await page.waitForTimeout(900);
1290 const chose = await inFrame(() => {
1291 const rows = [...document.querySelectorAll('.pickr')];
1292 for (const want of ['Back squat', 'Bench press']) {
1293 const r = rows.find(x => x.querySelector('.gap').textContent === want);
1294 if (r) r.click();
1295 }
1296 const add = document.querySelector('[data-a="padd"]');
1297 return {
1298 on: document.querySelectorAll('.pickr.on').length,
1299 label: add ? add.textContent : '',
1300 typed: !!document.getElementById('q'),
1301 };
1302 });
1303 await shotP('lifelog-1280-dark-workout-pick', 0);
1304
1305 // ── The selected-button fault, in the class it belongs to ──
1306 //
1307 // A selected surface goes to the accent ground; if its contents keep the ink they had,
1308 // what you have just chosen is the one thing on the screen you cannot read. Four kinds of
1309 // filled surface are measured at once — a chosen row, a pressed tile, the view switch,
1310 // the primary button — and reported WITH THEIR NUMBERS, because a contrast check that
1311 // says only "pass" is one nobody can argue with.
1312 //
1313 // What this found in the shipped page was two separate faults. `.tile.acc .tn` sat AFTER
1314 // the pressed rule at equal weight, so a pressed accent tile drew accent ink on an accent
1315 // ground at 1.00. And the page took the app's `accentText` on trust, which in this
1316 // palette is a light tint of the accent rather than ink to sit on it: every selected chip
1317 // in the whole capp measured 1.83.
1318 const ratios = [].concat(pressed,
1319 await contrastIn('.pickr.on'), await contrastIn('.chip.on'), await contrastIn('.go'));
1320 const unresolved = ratios.filter(x => x.r == null);
1321 const worst = ratios.filter(x => x.r != null).sort((a, b) => a.r - b.r)[0];
1322 check('SELECTED SURFACES INVERT THEIR INK, EVERY ONE OF THEM',
1323 pressed.length >= 2 && ratios.length >= 8 && unresolved.length === 0
1324 && !!worst && worst.r >= CONTRAST_MIN,
1325 ratios.length + ' surfaces (' + pressed.length + ' of them pressed), worst '
1326 + JSON.stringify(worst)
1327 + (unresolved.length ? ', unresolved: ' + JSON.stringify(unresolved.slice(0, 3)) : '')
1328 + ' | ' + ratios.map(x => x.n + '=' + x.r).join(' '));
1329
1330 await inFrame(() => {
1331 const b = document.querySelector('[data-a="padd"]');
1332 if (b) b.click();
1333 });
1334 await page.waitForTimeout(1800);
1335 const added = (await shardLines('gym')).filter(e => !gymWas.has(e.id) && e.id !== sessId);
1336 const g1 = await liveGroups();
1337 const names1 = g1.map(x => x.n).sort().join();
1338 check('THE PICKER TAKES SEVERAL AT ONCE AND ADDS EVERY ONE OF THEM',
1339 chose.on === 2 && chose.label === 'Add 2' && added.length === 2
1340 && names1 === 'Back squat,Bench press',
1341 JSON.stringify(chose) + ' | disk: ' + added.length + ' | groups: ' + names1);
1342 check('AND THEY ARRIVE AS A PLAN, UNDER THIS SESSION, WITH NOTHING TICKED',
1343 added.every(e => e.p === 1 && e.of === sessId)
1344 && g1.every(x => x.pend === 1 && x.green === 0),
1345 JSON.stringify(added.map(e => ({ p: e.p, of: e.of === sessId, f: e.f })))
1346 + ' | ' + JSON.stringify(g1));
1347
1348 // ── `prefill`: the same as last time, and never the same as a plan ──
1349 //
1350 // The last real squat was 110 x 3, three days back; the last real bench 85 x 4, yesterday.
1351 const valOf = (gs, n) => (gs.find(x => x.n === n) || { vals: [] }).vals;
1352 check('AND EACH OPENS ON THE LAST TIME THAT EXERCISE WAS REALLY DONE',
1353 valOf(g1, 'Back squat')[0] === '110x3' && valOf(g1, 'Bench press')[0] === '85x4',
1354 JSON.stringify(g1.map(x => x.n + ': ' + x.vals.join(' '))));
1355
1356 // Something wrong typed into the bench row, and NOT ticked. A plan is not a record, so the
1357 // next row must still come from the last real one — otherwise one mistyped weight walks
1358 // down the whole exercise and the log fills with numbers nobody lifted.
1359 // GUARDED, every one of these. A break that removes a group must redden the check that
1360 // names it and nothing else: an exception here would abort the run and take every later
1361 // check with it, and a break whose damage is hidden behind `the run completed` has proved
1362 // only that the file stops when it throws.
1363 await inFrame(() => {
1364 const g = [...document.querySelectorAll('.grp')].find(x => /Bench press/.test(x.textContent));
1365 const ins = g ? [...g.querySelectorAll('.setin')] : [];
1366 if (ins.length >= 2) { ins[0].value = '60'; ins[1].value = '20'; }
1367 });
1368 await inFrame(() => {
1369 const g = [...document.querySelectorAll('.grp')].find(x => /Bench press/.test(x.textContent));
1370 const b = g && g.querySelector('[data-a="addset"]');
1371 if (b) b.click();
1372 });
1373 await page.waitForTimeout(1600);
1374 const g2 = await liveGroups();
1375 check('AND A ROW THAT IS ONLY A PLAN IS NOT WHAT THE NEXT ONE COPIES',
1376 valOf(g2, 'Bench press').length === 2 && valOf(g2, 'Bench press')[0] === '60x20'
1377 && valOf(g2, 'Bench press')[1] === '85x4',
1378 JSON.stringify(g2.map(x => x.n + ': ' + x.vals.join(' '))));
1379
1380 // ── The tick, the rest clock, and the ding that is scheduled rather than counted ──
1381 await inFrame(() => {
1382 const g = [...document.querySelectorAll('.grp')].find(x => /Bench press/.test(x.textContent));
1383 const b = g && g.querySelector('.setr.pend [data-a="tick"]');
1384 if (b) b.click();
1385 });
1386 await page.waitForTimeout(1500);
1387 const rest1 = await inFrame(() => ({
1388 bar: !!document.querySelector('.rest'),
1389 clock: (document.getElementById('rest') || {}).textContent || '',
1390 said: (document.querySelector('.rest .ts') || {}).textContent || '',
1391 green: document.querySelectorAll('.setr .tick.on').length,
1392 }));
1393 await shotP('lifelog-1280-dark-workout-rest', 0);
1394 await page.waitForTimeout(3400);
1395 const midClock = await inFrame(() => (document.getElementById('rest') || {}).textContent || '');
1396 // A DIFFERENT exercise. The rest is between one effort and the next, not between one
1397 // exercise and the next, and a clock that only restarted within a group would be green on
1398 // any check that ticked the same lift twice.
1399 await inFrame(() => {
1400 const g = [...document.querySelectorAll('.grp')].find(x => /Back squat/.test(x.textContent));
1401 const b = g && g.querySelector('.setr.pend [data-a="tick"]');
1402 if (b) b.click();
1403 });
1404 await page.waitForTimeout(1300);
1405 const rest2 = await inFrame(() => (document.getElementById('rest') || {}).textContent || '');
1406 check('THE REST CLOCK STARTS ON A TICK AND STARTS AGAIN ON THE NEXT, IN ANY EXERCISE',
1407 rest1.bar && rest1.green >= 1
1408 && clockSecs(rest1.clock) >= 116 && clockSecs(rest1.clock) <= 120
1409 && clockSecs(midClock) <= clockSecs(rest1.clock) - 2
1410 && clockSecs(rest2) >= clockSecs(midClock) + 3 && clockSecs(rest2) >= 116,
1411 'first ' + rest1.clock + ' -> waited -> ' + midClock + ' -> other exercise -> ' + rest2);
1412 check('AND IT SAYS WHAT IT CANNOT PROMISE, rather than promising it',
1413 /may not ring/i.test(rest1.said), JSON.stringify(rest1.said));
1414
1415 // ── Room for the numbers, on the screen that is all numbers ──
1416 //
1417 // This is the live screen, so every control this work added is on it at once: the weight
1418 // and rep boxes, the ticks, the clock, the chips and the Finish button. A box shorter
1419 // than its own type is invisible text that every value assertion above would still call
1420 // correct — the failure this check exists for, and the reason it measures the room rather
1421 // than reading the value back.
1422 const room = [].concat(await roomIn('.setin'), await roomIn('.tick'), await roomIn('.rt'),
1423 await roomIn('.chip.on'), await roomIn('.go'));
1424 const cramped = room.filter(x => x.room < x.fs || x.overW > 1 || x.overH > 1);
1425 check('AND EVERY CONTROL HAS MORE ROOM THAN ITS OWN TYPE NEEDS',
1426 room.length >= 8 && cramped.length === 0,
1427 room.length + ' controls measured, ' + cramped.length + ' cramped'
1428 + (cramped.length ? ': ' + JSON.stringify(cramped.slice(0, 3)) : '')
1429 + ' | tightest ' + JSON.stringify(room.slice().sort((a, b) => (a.room - a.fs) - (b.room - b.fs))[0]));
1430
1431 // The ding cannot be a file under this policy, so it is synthesised — and it is SCHEDULED
1432 // on the audio clock two minutes out rather than fired by an interval, which is the only
1433 // version of it that survives a throttled tab. The offsets are the only place the
1434 // difference between the two shows at all.
1435 const au = await inFrame(() => window.__audio);
1436 check('AND THE DING IS SCHEDULED ON THE AUDIO CLOCK, NOT COUNTED DOWN BY A TIMER',
1437 au.ctx === 1 && au.osc >= 4 && au.when.length >= 4
1438 && au.when.every(w => w >= 115 && w <= 125),
1439 JSON.stringify(au) + ' — one context opened on the press, tones due ~120s out');
1440
1441 // ── The gap between two correct checks ──
1442 //
1443 // Each group heading is right, and each row is right, and the two of them together still
1444 // permit the session's own total to be something else entirely — a leaked scope, a
1445 // double-counted plan, a sum over the wrong set. So the three numbers are compared with
1446 // each other AND with arithmetic done here: 60 x 20 and 110 x 3, the two rows that were
1447 // actually ticked, and not one thing more.
1448 const DONE_VOL = 60 * 20 + 110 * 3;
1449 const g3 = await liveGroups();
1450 const bar3 = await inFrame(() => (document.querySelector('.scope .ts') || {}).textContent || '');
1451 const groupSum = g3.reduce((a, x) => a + numOf(x.vol), 0);
1452 const barVol = numOf(String(bar3).split('·')[1] || '');
1453 check('THE SESSION\'S TOTAL IS THE SUM OF ITS GROUPS AND OF NOTHING ELSE',
1454 groupSum === DONE_VOL && barVol === DONE_VOL,
1455 'groups ' + JSON.stringify(g3.map(x => x.n + '=' + x.vol)) + ' sum ' + groupSum
1456 + ', scope bar ' + JSON.stringify(bar3) + ' -> ' + barVol + ', arithmetic ' + DONE_VOL);
1457
1458 // ── A finish, and what the closed session then reads as ──
1459 await inFrame(() => {
1460 const b = document.querySelector('[data-a="finish"]');
1461 if (b) b.click();
1462 });
1463 await page.waitForTimeout(1700);
1464 const fin = await inFrame(() => ({
1465 scope: !!document.querySelector('.scope'),
1466 dates: !!document.querySelector('[data-a="off"]'),
1467 rows: [...document.querySelectorAll('.er .en')].map(x => x.firstChild.textContent),
1468 said: [...document.querySelectorAll('.er small')].map(x => x.textContent).join(' | '),
1469 }));
1470 const closed = (await shardLines('gym')).filter(e => e.id === sessId).pop();
1471 await shotP('lifelog-1280-dark-workout-done', 0);
1472 check('A FINISH CLOSES IT, AND NOTHING TYPED A DURATION ANYWHERE',
1473 !!closed && !!closed.end && (closed.f || {}).dur == null
1474 && !fin.scope && fin.dates && fin.rows.includes('Morning')
1475 && /2 logged/.test(fin.said) && /1 to do/.test(fin.said),
1476 JSON.stringify({ end: closed && closed.end, f: closed && closed.f }) + ' | '
1477 + JSON.stringify(fin));
1478
1479 // AND THE SAME LENGTH READ BACK THE ORDINARY WAY. The scope bar works out a running
1480 // session's length for itself, so it is green whether or not a CLOSED session's duration
1481 // falls out of its stamps — which is what the first version of this block missed: the
1482 // break that removes the derivation changed nothing at all and reported green. The
1483 // reading that actually needs it is the lane's own `dur` field in Life, where five
1484 // finished sessions have to add up without a single typed duration between them.
1485 await inFrame(() => document.querySelector('[data-a="view"][data-v="life"]').click());
1486 await page.waitForTimeout(800);
1487 await inFrame(() => document.querySelector('[data-a="per"][data-v="month"]').click());
1488 await page.waitForTimeout(1300);
1489 const gymLife = await inFrame(() => [...document.querySelectorAll('.st')]
1490 .map(x => x.querySelector('.sk').textContent + '=' + x.querySelector('.sv').textContent));
1491 const timeStat = (gymLife.find(x => /^Time=/.test(x)) || '=').split('=')[1];
1492 const mHM = /(?:(\d+)h)?\s*(?:(\d+)m)?/.exec(timeStat || '');
1493 const mins = timeStat && /\d/.test(timeStat) ? Number(mHM[1] || 0) * 60 + Number(mHM[2] || 0) : -1;
1494 check('AND EVERY FINISHED SESSION\'S LENGTH IS READ BACK FROM ITS STAMPS, NOT A FIELD',
1495 mins >= SESS_IN_MONTH * SESS_MIN && mins <= SESS_IN_MONTH * SESS_MIN + 2,
1496 'Time reads ' + JSON.stringify(timeStat) + ' = ' + mins + ' minutes; expected '
1497 + SESS_IN_MONTH + ' x ' + SESS_MIN + ' plus the one just finished | ' + gymLife.join(' '));
1498 await inFrame(() => document.querySelector('[data-a="per"][data-v="day"]').click());
1499 await page.waitForTimeout(700);
1500 await inFrame(() => document.querySelector('[data-a="view"][data-v="log"]').click());
1501 await page.waitForTimeout(500);
1502
1503 // The body lane, which is where `last`, `mean` and `since` are.
1504 await inFrame(() => document.querySelector('[data-a="lane"][data-v="body"]').click());
1505 await page.waitForTimeout(1200);
1506 // The readings are read back in LIFE now, which is the point of the axis: the body lane's
1507 // numbers are reporting and its four tiles are entry.
1508 await inFrame(() => document.querySelector('[data-a="view"][data-v="life"]').click());
1509 await page.waitForTimeout(900);
1510 const bodyStats = await inFrame(() => [...document.querySelectorAll('.st')]
1511 .map(x => x.querySelector('.sk').textContent + '=' + x.querySelector('.sv').textContent));
1512 // `last` on a word, `since` as an age, `sum` with a rollup on a duration: three aggregations
1513 // that a numeric-only reading would have shown as a dash.
1514 check('the body lane reads `last`, `since` and a duration rather than dashes',
1515 bodyStats.length === 5 && !bodyStats.some(x => /=—/.test(x)),
1516 bodyStats.join(' '));
1517 await shotP('lifelog-1280-dark-body', 0);
1518
1519 // ── 4. A tap, and whether it reached the disk ──
1520 //
1521 // The fixture ALREADY contains `"src":"preset"` lines. Testing the file for that string is
1522 // green before the tap and green if the tile does nothing, which is what the first version of
1523 // this check did. So: the ids that were in the file, then the ids that are, and the
1524 // difference has to be exactly the coffee.
1525 await inFrame(() => document.querySelector('[data-a="lane"][data-v="diet"]').click());
1526 await page.waitForTimeout(1000);
1527 await inFrame(() => document.querySelector('[data-a="view"][data-v="log"]').click());
1528 await page.waitForTimeout(700);
1529 const before = await shardLines();
1530 const beforeIds = new Set(before.map(e => e.id));
1531 await inFrame(() => document.querySelector('[data-a="tile"][data-v="7"]').click()); // Coffee, a preset
1532 await page.waitForTimeout(1200);
1533 const after = await shardLines();
1534 const fresh = after.filter(e => !beforeIds.has(e.id));
1535 const kept = before.every((e, i) => after[i] && after[i].id === e.id
1536 && JSON.stringify(after[i].f) === JSON.stringify(e.f));
1537 check('A TAP REACHES THE DISK as one new line and nothing else moves',
1538 fresh.length === 1 && fresh[0].src === 'preset' && fresh[0].f.food === 'coff'
1539 && fresh[0].day === today && kept,
1540 'new: ' + JSON.stringify(fresh.map(e => ({ src: e.src, f: e.f })))
1541 + ' | ' + before.length + ' -> ' + after.length + ' lines, earlier lines intact: ' + kept);
1542 await shotP('lifelog-1280-dark-tapped', 0);
1543
1544 // ── 5. The scale tile: adjust, then log the ADJUSTED amount ──
1545 // Two presses of `+` on a tile whose default is 80 g and whose step is 10 g. A tile that
1546 // logged its default would give 80 and a tile that took one press would give 90, so the
1547 // number is the whole assertion.
1548 await inFrame(() => document.querySelector('[data-a="tile"][data-v="0"]').click());
1549 await page.waitForTimeout(400);
1550 await shotP('lifelog-1280-dark-scale', 0);
1551 const shown = await inFrame(() => {
1552 // Re-queried between clicks: the first one redraws and the element it was on is detached,
1553 // and a detached node's click never reaches the delegated listener on `document`.
1554 document.querySelectorAll('[data-a="amt"]')[1].click();
1555 document.querySelectorAll('[data-a="amt"]')[1].click();
1556 return document.querySelector('.amt').textContent;
1557 });
1558 await page.waitForTimeout(300);
1559 await inFrame(() => document.querySelector('[data-a="logscale"]').click());
1560 await page.waitForTimeout(1200);
1561 const scaled = (await shardLines()).filter(x => x.src === 'scale' && x.f && x.f.food === 'oats');
1562 check('A SCALE TILE LOGS THE ADJUSTED AMOUNT, not the tile\'s default',
1563 scaled.length === 1 && Number(scaled[0].f.g) === 100 && numOf(shown) === 100,
1564 'stepper showed ' + JSON.stringify(shown) + ', disk says '
1565 + JSON.stringify(scaled.map(x => x.f.g)));
1566
1567 // The form screen opens on the fields the tile names.
1568 await inFrame(() => {
1569 const t = document.querySelectorAll('[data-a="tile"]');
1570 t[t.length - 1].click();
1571 });
1572 await page.waitForTimeout(500);
1573 const form = await inFrame(() => ({
1574 labels: [...document.querySelectorAll('.fl')].map(x => x.textContent),
1575 commit: !!document.querySelector('[data-a="commit"]'),
1576 }));
1577 check('a form tile opens on the fields it names',
1578 form.commit && form.labels.some(x => /Amount/.test(x)) && form.labels.some(x => /Meal/.test(x)),
1579 form.labels.join(' | '));
1580 await shotP('lifelog-1280-dark-form', 0);
1581 await inFrame(() => document.querySelector('[data-a="back"]').click());
1582 await page.waitForTimeout(400);
1583
1584 // ── 6. An edit, and a delete that leaves a tombstone ──
1585 // The rows are counted on the LOG screen, before the form covers it: the form draws no `.er`
1586 // at all, so counting from in there would compare 0 with 12 and pass for a reason that has
1587 // nothing to do with deleting anything.
1588 const rowsBefore = await inFrame(() => document.querySelectorAll('.er').length);
1589 await inFrame(() => document.querySelectorAll('[data-a="edit"]')[0].click());
1590 await page.waitForTimeout(500);
1591 await shotP('lifelog-1280-dark-edit', 0);
1592 const target = await inFrame(() => {
1593 const d = document.querySelector('[data-a="del"]');
1594 return d ? d.getAttribute('data-v') : '';
1595 });
1596 check('an entry opens in a form that can delete it', !!target, target);
1597 await inFrame(() => document.querySelector('[data-a="del"]').click());
1598 await page.waitForTimeout(1200);
1599 const tomb = (await shardLines()).filter(x => x.id === target && x.del);
1600 const rowsAfter = await inFrame(() => document.querySelectorAll('.er').length);
1601 check('A DELETE LEAVES A TOMBSTONE ON DISK AND THE ROW GOES',
1602 tomb.length === 1 && rowsAfter === rowsBefore - 1,
1603 'tombstones: ' + tomb.length + ', rows ' + rowsBefore + ' -> ' + rowsAfter);
1604
1605 // ── 10. The +: a lane made from inside the page ──
1606 //
1607 // notes6 item 1. A lane is this capp's primitive, three arrived furnished, and there was no
1608 // way to make a fourth. What is asserted is not that a screen opened. It is that the lane
1609 // reached the DISK coherent, that it arrived with something to press, that pressing it put a
1610 // line in that lane's OWN shard, and that Life read the line back. A lane you cannot log into
1611 // is the failure this block exists to catch, and it is the one the owner would meet first.
1612 const lanesBefore = (await jsonAt('index.json')).lanes.slice();
1613 await inFrame(() => document.querySelector('[data-a="lanenew"]').click());
1614 await page.waitForTimeout(600);
1615 await shotP('lifelog-1280-dark-newlane', 0);
1616 const builder = await inFrame(() => ({
1617 name: !!document.querySelector('[data-lin="n"]'),
1618 types: [...document.querySelectorAll('[data-a="ftype"]')].map(x => x.getAttribute('data-v')),
1619 save: !!document.querySelector('[data-a="lanesave"]'),
1620 }));
1621 const idxOpen = await jsonAt('index.json');
1622 check('THE + OPENS A LANE BUILDER, and opening it has written nothing',
1623 builder.name && builder.save
1624 && ['num', 'dur', 'enum', 'bool', 'text'].every(t => builder.types.includes('0|' + t))
1625 && idxOpen.lanes.join() === lanesBefore.join(),
1626 builder.types.join(' ') + ' | index: ' + idxOpen.lanes.join());
1627
1628 // Named but with no field in it. A lane with no fields draws nothing and logs nothing, so
1629 // this must not become one — and it must say which of the two things is missing rather than
1630 // doing nothing and leaving the person pressing.
1631 await inFrame(() => { document.querySelector('[data-lin="n"]').value = 'Reading'; });
1632 await inFrame(() => document.querySelector('[data-a="lanesave"]').click());
1633 await page.waitForTimeout(1000);
1634 const refused = await inFrame(() => ({
1635 still: !!document.querySelector('[data-a="lanesave"]'),
1636 said: [...document.querySelectorAll('.hint, .toast')].map(x => x.textContent).join(' | '),
1637 }));
1638 const idxEmpty = await jsonAt('index.json');
1639 check('AN EMPTY LANE IS REFUSED, and the screen says what is missing',
1640 idxEmpty.lanes.join() === lanesBefore.join() && refused.still
1641 && /at least one field/i.test(refused.said),
1642 'index: ' + idxEmpty.lanes.join() + ' | said: ' + refused.said.slice(0, 100));
1643
1644 // A number with a unit, and a yes/no — between them they cover the two things a made lane
1645 // has to get right: something to add up, and something to tap.
1646 await inFrame(() => { document.querySelector('[data-lin="0.n"]').value = 'Pages'; });
1647 await inFrame(() => { document.querySelector('[data-lin="0.u"]').value = 'pages'; });
1648 // A daily target, because the bar and the tick beside a number in Life are the whole of what
1649 // "reporting" means to somebody keeping a log, and they come from here.
1650 await inFrame(() => { document.querySelector('[data-lin="0.tv"]').value = '20'; });
1651 await inFrame(() => document.querySelector('[data-a="fadd"]').click());
1652 await page.waitForTimeout(600);
1653 await inFrame(() => { document.querySelector('[data-lin="1.n"]').value = 'Finished a book'; });
1654 await inFrame(() => document.querySelector('[data-a="ftype"][data-v="1|bool"]').click());
1655 await page.waitForTimeout(600);
1656 await shotP('lifelog-1280-dark-newlane-filled', 0);
1657 await inFrame(() => document.querySelector('[data-a="lanesave"]').click());
1658 await page.waitForTimeout(2000);
1659
1660 const made = await jsonAt('lanes/reading.json');
1661 const idxAfter = await jsonAt('index.json');
1662 const mf = (made && made.fields) || [];
1663 const pages = mf.find(f => f.k === 'pages'), finished = mf.find(f => f.t === 'bool');
1664 const tgt = ((made && made.targets) || []).find(t => t.f === 'pages');
1665 check('A LANE MADE FROM THE PAGE REACHES THE DISK, and points at nothing that is not there',
1666 !!made && idxAfter.lanes.includes('reading')
1667 && !!pages && pages.t === 'num' && pages.u === 'pages' && pages.agg === 'sum'
1668 && !!finished && finished.n === 'Finished a book'
1669 && !!tgt && tgt.p === 'day' && tgt.dir === 'min' && tgt.v === 20
1670 && laneComplaints(made).length === 0,
1671 JSON.stringify({ lanes: idxAfter.lanes, fields: mf.map(f => f.k + ':' + f.t), target: tgt,
1672 complaints: made ? laneComplaints(made) : ['no file at all'] }));
1673
1674 const padNow = await inFrame(() => ({
1675 on: (document.querySelector('[data-a="lane"].on') || { textContent: '' }).textContent,
1676 tiles: [...document.querySelectorAll('.pad .tile .tn')].map(x => x.textContent),
1677 }));
1678 check('IT ARRIVES WITH TILES TO PRESS, on the lane the page has switched to',
1679 padNow.on === 'Reading' && padNow.tiles.includes('Finished a book')
1680 && padNow.tiles.includes('New entry'), JSON.stringify(padNow));
1681
1682 const tapped = await inFrame(() => {
1683 const t = [...document.querySelectorAll('.pad .tile')]
1684 .find(x => /Finished a book/.test(x.textContent));
1685 if (!t) return false;
1686 t.click(); return true;
1687 });
1688 await page.waitForTimeout(1600);
1689 const readingLines = await shardLines('reading');
1690 check('AND PRESSING ONE PUTS A LINE IN THAT LANE\'S OWN SHARD',
1691 tapped && readingLines.length === 1 && readingLines[0].f.finished_a_book === true
1692 && readingLines[0].day === today && readingLines[0].src === 'tap',
1693 JSON.stringify(readingLines.map(e => ({ src: e.src, day: e.day, f: e.f }))));
1694
1695 await inFrame(() => document.querySelector('[data-a="view"][data-v="life"]').click());
1696 await page.waitForTimeout(1000);
1697 const newLife = await inFrame(() => [...document.querySelectorAll('.st')]
1698 .map(x => x.querySelector('.sk').textContent + '=' + x.querySelector('.sv').textContent));
1699 await shotP('lifelog-1280-dark-newlane-life', 0);
1700 check('AND LIFE READS THE NEW LANE BACK: the tap counted, not a dash',
1701 newLife.some(x => /^Finished a book=1/.test(x)), newLife.join(' '));
1702
1703 // ── The edit that must not eat the lanes that ship ──
1704 //
1705 // The screen has no vocabulary for a catalogue, a `ref` that reads one, or a field computed
1706 // from a formula — and the diet lane is made of all three. So an edit through it is a PATCH:
1707 // what it cannot show it carries, and the field it adds is reachable from the pad the same
1708 // day. Without this the `+` would be a way to flatten the three lanes that arrive furnished.
1709 await inFrame(() => document.querySelector('[data-a="lane"][data-v="diet"]').click());
1710 await page.waitForTimeout(1200);
1711 const dietBefore = await jsonAt('lanes/diet.json');
1712 await inFrame(() => document.querySelector('[data-a="laneedit"]').click());
1713 await page.waitForTimeout(700);
1714 const editRows = await inFrame(() => [...document.querySelectorAll('.fbox input')]
1715 .filter(x => /\.n$/.test(x.getAttribute('data-lin'))).map(x => x.value));
1716 check('the builder offers the fields it can show and leaves the rest alone',
1717 editRows.includes('Meal') && editRows.includes('Amount')
1718 && !editRows.includes('Food') && !editRows.includes('Energy'), editRows.join(' | '));
1719 await inFrame(() => document.querySelector('[data-a="fadd"]').click());
1720 await page.waitForTimeout(600);
1721 await inFrame((i) => { document.querySelector('[data-lin="' + i + '.n"]').value = 'Water'; },
1722 editRows.length);
1723 await inFrame((i) => { document.querySelector('[data-lin="' + i + '.u"]').value = 'ml'; },
1724 editRows.length);
1725 await shotP('lifelog-1280-dark-laneedit', 0);
1726 await inFrame(() => document.querySelector('[data-a="lanesave"]').click());
1727 await page.waitForTimeout(2000);
1728 const dietAfter = await jsonAt('lanes/diet.json');
1729 const fkeys = (l) => ((l && l.fields) || []).map(f => f.k);
1730 const tnames = (l) => ((l && l.tiles) || []).map(t => t.n);
1731 const kcal = ((dietAfter && dietAfter.fields) || []).find(f => f.k === 'kcal');
1732 const amount = ((dietAfter && dietAfter.fields) || []).find(f => f.k === 'g');
1733 const genForm = ((dietAfter && dietAfter.tiles) || []).find(t => t.gen && t.m === 'form');
1734 check('AN EDIT KEEPS WHAT THE DAIMON WROTE, and the field it adds is usable that day',
1735 !!kcal && !!kcal.d && kcal.d.c === 'food' && kcal.d.per === 100
1736 && fkeys(dietAfter).includes('food') && dietAfter.primary === 'food'
1737 && !!amount && amount.step === 10
1738 && tnames(dietBefore).every(nm => tnames(dietAfter).includes(nm))
1739 && fkeys(dietAfter).includes('water') && (dietAfter.show || []).includes('water')
1740 && !!genForm && genForm.fs.includes('water')
1741 && laneComplaints(dietAfter).length === 0,
1742 JSON.stringify({ fields: fkeys(dietAfter), kcal: kcal && kcal.d, step: amount && amount.step,
1743 tiles: tnames(dietAfter), complaints: laneComplaints(dietAfter) }));
1744
1745 // ── Light ink, and the warm skin ──
1746 await page.evaluate(() => window.DaimondTheme && window.DaimondTheme.set('light'));
1747 await page.waitForTimeout(1200);
1748 await shotP('lifelog-1280-light-log', 0);
1749 await shot('lifelog-1280-light-whole');
1750 // A `data` message arrives on every theme change, and the page must be idempotent under it:
1751 // still up, still on the diet lane, still showing the same rows.
1752 const afterInk = await page.evaluate(() => ({
1753 up: !!document.querySelector('.crystal-frame'),
1754 fell: !!document.querySelector('.crystal-fallback-note'),
1755 }));
1756 check('a theme change re-sends `data` and the page survives it',
1757 afterInk.up && !afterInk.fell, JSON.stringify(afterInk));
1758 await inFrame(() => document.querySelector('[data-a="view"][data-v="life"]').click());
1759 await page.waitForTimeout(700);
1760 await shotP('lifelog-1280-light-charts', 560);
1761 await inFrame(() => document.querySelector('[data-a="view"][data-v="log"]').click());
1762 await page.waitForTimeout(400);
1763
1764 // A lane takes longer to describe than an entry does, and `data` arrives again on every
1765 // theme, ink and skin change — so the redraw those force must not type over what is being
1766 // written. Same property as the half-filled entry form, on the screen where it costs most.
1767 await inFrame(() => document.querySelector('[data-a="lanenew"]').click());
1768 await page.waitForTimeout(500);
1769 await inFrame(() => { document.querySelector('[data-lin="n"]').value = 'Half typed'; });
1770 await page.evaluate(() => window.DaimondTheme && window.DaimondTheme.set('dark'));
1771 await page.waitForTimeout(1400);
1772 const stillThere = await inFrame(() => {
1773 const i = document.querySelector('[data-lin="n"]');
1774 return i ? i.value : '(the builder is not even up)';
1775 });
1776 check('A HALF-BUILT LANE SURVIVES THE REDRAW A THEME CHANGE FORCES',
1777 stillThere === 'Half typed', JSON.stringify(stillThere));
1778 await inFrame(() => document.querySelector('[data-a="back"]').click());
1779 await page.waitForTimeout(400);
1780
1781 // THE REDRAW NOBODY ASKED FOR, and the one that actually bit: a toast clears itself six
1782 // seconds after it appeared and redraws the page from state. Anything typed since and not yet
1783 // read back out of the DOM is gone. This is not hypothetical — the first run of the block
1784 // above lost the `Water` field exactly this way, and the entry form had carried the same
1785 // fault since it was written. So the toast is started first and nothing is clicked after the
1786 // typing, because a click of any kind is what used to rescue it.
1787 await inFrame(() => document.querySelector('[data-a="tile"][data-v="7"]').click()); // Coffee
1788 await page.waitForTimeout(700);
1789 await inFrame(() => document.querySelector('[data-a="lanenew"]').click());
1790 await page.waitForTimeout(400);
1791 await inFrame(() => { document.querySelector('[data-lin="n"]').value = 'Typed while a toast was up'; });
1792 await page.waitForTimeout(6800);
1793 const survived = await inFrame(() => {
1794 const i = document.querySelector('[data-lin="n"]');
1795 return i ? i.value : '(the builder is not even up)';
1796 });
1797 check('AND SO DOES THE REDRAW A TOAST FORCES WHEN IT CLEARS ITSELF',
1798 survived === 'Typed while a toast was up', JSON.stringify(survived));
1799 await inFrame(() => document.querySelector('[data-a="back"]').click());
1800 await page.waitForTimeout(400);
1801 await page.evaluate(() => window.DaimondTheme && window.DaimondTheme.set('light'));
1802 await page.waitForTimeout(1000);
1803
1804 // ── The phone ──
1805 await mount(375, 812);
1806 await shotP('lifelog-375-light-log', 0);
1807 await shot('lifelog-375-light-whole');
1808 await page.evaluate(() => window.DaimondTheme && window.DaimondTheme.set('dark'));
1809 await page.waitForTimeout(1200);
1810 await shotP('lifelog-375-dark-log', 0);
1811 await shotP('lifelog-375-dark-log-2', 560);
1812 const narrow = await inFrame(() => ({
1813 wide: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
1814 w: document.documentElement.clientWidth,
1815 cols: getComputedStyle(document.querySelector('.pad')).gridTemplateColumns.split(' ').length,
1816 }));
1817 check('the phone width does not scroll sideways', !narrow.wide, JSON.stringify(narrow));
1818 // AND THE SWITCH IS ALL THERE. `scrollWidth` cannot see this one: the lane chips live in a
1819 // scroller, and anything clipped INSIDE a scroller leaves the page's own width untouched. At
1820 // 375px with four lanes the switch was half off the right edge and every other check was
1821 // green. So the assertion is the button's own rectangle against the page's width.
1822 const swi = await inFrame(() => {
1823 const r = { w: document.documentElement.clientWidth };
1824 ['log', 'life'].forEach(v => {
1825 const el = document.querySelector('[data-a="view"][data-v="' + v + '"]');
1826 r[v] = el ? Math.round(el.getBoundingClientRect().right) : -1;
1827 });
1828 return r;
1829 });
1830 check('THE LOG/LIFE SWITCH IS WHOLLY ON THE SCREEN AT PHONE WIDTH',
1831 swi.log > 0 && swi.life > 0 && swi.life <= swi.w, JSON.stringify(swi));
1832 await inFrame(() => document.querySelector('[data-a="view"][data-v="life"]').click());
1833 await page.waitForTimeout(900);
1834 await shotP('lifelog-375-dark-charts', 520);
1835
1836 // The builder is the widest thing on the page — an input, five type chips, four aggregation
1837 // chips and a target — and a phone is where it will be used. The rest of the page is held to
1838 // not scrolling sideways; so is this.
1839 await inFrame(() => document.querySelector('[data-a="lanenew"]').click());
1840 await page.waitForTimeout(600);
1841 await shotP('lifelog-375-dark-newlane', 0);
1842 const buildNarrow = await inFrame(() => ({
1843 wide: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
1844 box: document.querySelector('.fbox').getBoundingClientRect().width,
1845 w: document.documentElement.clientWidth,
1846 }));
1847 check('the lane builder does not scroll sideways on a phone',
1848 !buildNarrow.wide && buildNarrow.box <= buildNarrow.w, JSON.stringify(buildNarrow));
1849 await inFrame(() => document.querySelector('[data-a="back"]').click());
1850 await page.waitForTimeout(400);
1851
1852 // Back to Log, because a session is opened from an entry row and there are no rows in Life.
1853 // Without this the shot below was taken on the charts and labelled `session`.
1854 await inFrame(() => document.querySelector('[data-a="view"][data-v="log"]').click());
1855 await page.waitForTimeout(500);
1856 await inFrame(() => document.querySelector('[data-a="lane"][data-v="gym"]').click());
1857 await page.waitForTimeout(1000);
1858 await inFrame(() => {
1859 const r = document.querySelector('[data-a="sess"]');
1860 if (r) r.click();
1861 });
1862 await page.waitForTimeout(700);
1863 await shotP('lifelog-375-dark-session', 0);
1864
1865 // The skin changes the radii and the typeface under the page. It is not reloaded for it: the
1866 // app re-sends `data` on the attribute change and the page has to follow, live.
1867 //
1868 // BOTH WAYS, and starting from `sharp` rather than ending at `warm`. A fresh account has no
1869 // stored view, so `initView` gives it `simple`, and `setView` gives Simple the WARM skin —
1870 // so a run that only ever set 'warm' was setting what was already there and comparing a
1871 // value with itself. It read 14px -> 14px and reported the page as not following.
1872 const rd = () => inFrame(() => getComputedStyle(document.documentElement)
1873 .getPropertyValue('--rd').trim());
1874 await page.evaluate(() => window.DaimondSkin.set('sharp'));
1875 await page.waitForTimeout(1400);
1876 const rdSharp = await rd();
1877 await page.evaluate(() => window.DaimondSkin.set('warm'));
1878 await page.waitForTimeout(1400);
1879 const rdWarm = await rd();
1880 // The app's own shape tokens (variables.css): 8px sharp, 14px warm. Asserted as the values
1881 // and not merely as a difference, so a page that echoed any two strings would not pass.
1882 check('THE PAGE FOLLOWS A LIVE SKIN CHANGE, IN BOTH DIRECTIONS',
1883 rdSharp === '8px' && rdWarm === '14px', 'sharp:' + rdSharp + ' warm:' + rdWarm);
1884 await shotP('lifelog-375-dark-warm', 0);
1885
1886 // The 502s are the account service, which no world has; they say nothing about the page.
1887 const errs = errors(s).filter(e => !/502|account/i.test(e));
1888 check('no console errors', errs.length === 0, errs.slice(0, 3).join(' | '));
1889} catch (e) {
1890 check('the run completed', false, String((e && e.stack) || e));
1891} finally {
1892 await s.close?.().catch(() => {});
1893}
1894
1895console.log(`\n${ok.length} passed, ${bad.length} failed`);
1896if (BREAK) {
1897 console.log(bad.length
1898 ? `\nbreak '${BREAK}' produced failures, as it must:\n ${bad.join('\n ')}`
1899 : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`);
1900}
1901process.exit(bad.length ? 1 : 0);