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/. |
| 53 | import fs from 'node:fs'; |
| 54 | import path from 'node:path'; |
| 55 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 56 | |
| 57 | const 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. |
| 62 | const ROOT = path.join(HERE, '..'); |
| 63 | const PAGE_PATH = path.join(ROOT, 'www', 'capps', 'lifelog', 'crystal.html'); |
| 64 | if (!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 | } |
| 69 | const H = await import(pathToFileURL(path.join(ROOT, 'dev', 'harness.mjs')).href); |
| 70 | const { 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. |
| 75 | const 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 | })(); |
| 81 | let PAGE = fs.readFileSync(PAGE_PATH, 'utf8'); |
| 82 | const DIR = path.dirname(PAGE_PATH); |
| 83 | /// What the template carries besides its page, by its own manifest. |
| 84 | const MANIFEST = JSON.parse(fs.readFileSync(path.join(DIR, 'capp.json'), 'utf8')); |
| 85 | const CARRIES = MANIFEST.files.filter(f => f !== 'crystal.html'); |
| 86 | const 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. |
| 100 | const SEED_MODE = process.argv.includes('--seed') || process.argv.includes('noseed'); |
| 101 | const SHOTS = process.env.SHOTS || path.dirname(scratch('shots', 'x')); |
| 102 | fs.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. |
| 110 | const CONTRAST_MIN = 3.0; |
| 111 | |
| 112 | const ok = [], bad = []; |
| 113 | const 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. |
| 124 | const 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 | |
| 381 | const BREAK = (() => { |
| 382 | const i = process.argv.indexOf('--break'); |
| 383 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 384 | })(); |
| 385 | if (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. |
| 406 | const bytes = Buffer.byteLength(PAGE, 'utf8'); |
| 407 | check('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. |
| 413 | const 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. |
| 425 | const CODE = PAGE.replace(/\/\*[\s\S]*?\*\//g, '').split('\n') |
| 426 | .filter(l => !/^\s*\/\//.test(l)).join('\n'); |
| 427 | const found = FORBIDDEN.filter(([re]) => re.test(CODE)).map(([, n]) => n); |
| 428 | check('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 ── |
| 541 | const p2 = n => (n < 10 ? '0' : '') + n; |
| 542 | const ymd = d => d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()); |
| 543 | const 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 | }; |
| 548 | let n = 0; |
| 549 | const nid = () => 'seed' + (++n).toString(36); |
| 550 | const 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 | }; |
| 555 | const 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. |
| 563 | const 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 | |
| 568 | const diet = [], gym = [], body = []; |
| 569 | const BREAKFAST = [['oats', 80], ['milk', 250], ['ban', 120]]; |
| 570 | const LUNCH = [['bread', 120], ['ched', 40], ['app', 150]]; |
| 571 | const DINNER = [['chick', 200], ['rice', 250], ['broc', 150]]; |
| 572 | const GAP_DAY = 5; // one day with nothing in it, so a streak and a chart mean something |
| 573 | let todayKcal = 0; |
| 574 | for (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. |
| 600 | const PLANNED = [['chick', 300], ['rice', 300]]; |
| 601 | let plannedKcal = 0; |
| 602 | for (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. |
| 609 | const gapPlan = ent(GAP_DAY, 12, 0, { food: 'app', g: 150, meal: 'Lunch' }, 'pick'); |
| 610 | gapPlan.p = 1; |
| 611 | diet.push(gapPlan); |
| 612 | const 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. |
| 617 | const TODAY_FOODS = new Set(diet.filter(e => e.day === ymd(new Date()) && !e.p).map(e => e.f.food)); |
| 618 | const 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. |
| 621 | const PUSH = [['bp', 85, 5], ['bp', 85, 4], ['ohp', 47.5, 6], ['push', 25, 15], ['dip', 0, 10]]; |
| 622 | const 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 | ]; |
| 629 | const SESS_MIN = 62; |
| 630 | for (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. |
| 647 | const THIS_MONTH = ymd(new Date()).slice(0, 7); |
| 648 | const 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. |
| 651 | const PUSH_LIFTS = [...new Set(PUSH.map(x => x[0]))]; |
| 652 | const BENCH_VOL = PUSH.filter(x => x[0] === 'bp').reduce((a, [, kg, reps]) => a + kg * reps, 0); |
| 653 | |
| 654 | const 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. |
| 662 | const ym = ymd(new Date()).slice(0, 7); |
| 663 | const today = ymd(new Date()); |
| 664 | |
| 665 | // ── Drive it ────────────────────────────────────────────────────── |
| 666 | const s = await open({ name: 'lifelog', signIn: false, connect: false }); |
| 667 | const { page } = s; |
| 668 | const 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. |
| 676 | const 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. |
| 709 | const 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. |
| 715 | const 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. |
| 722 | const numOf = (txt) => { |
| 723 | const m = /-?[\d,]*\.?\d+/.exec(String(txt || '').replace(/\s/g, '')); |
| 724 | return m ? Number(m[0].replace(/,/g, '')) : NaN; |
| 725 | }; |
| 726 | |
| 727 | let 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. |
| 730 | const 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. |
| 742 | const 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. |
| 752 | const 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. |
| 776 | const 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. |
| 781 | const 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. |
| 789 | const 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. |
| 795 | const 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. |
| 812 | const 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. |
| 835 | const 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 | |
| 870 | try { |
| 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 | |
| 1895 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 1896 | if (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 | } |
| 1901 | process.exit(bad.length ? 1 : 0); |