591 lines
28 KiB
JavaScript
591 lines
28 KiB
JavaScript
// The wall, checked where it can actually go wrong: in a browser, on a shelf long
|
|
// enough that the wall is drawing a few of its rows and standing in for the rest.
|
|
//
|
|
// One failure is what this guards, and it is the one that has already cost a
|
|
// white screen: a window written from the window before it. The wall measures
|
|
// itself after every render, writes what it measured, and renders again — so an
|
|
// input the measure takes off the window it just wrote feeds itself, pass after
|
|
// pass, until React gives up with #185, unmounts the tree, and leaves the reader
|
|
// with nothing (there is no error boundary under it).
|
|
//
|
|
// The trigger is not spelled out — no browser reproduces the reader's own,
|
|
// which is the point — so the check drives the *class*: the wall's own box
|
|
// reported at two different heights on every pass, plus one scroll to start it.
|
|
// That is what any read-back is, and the wall must survive it.
|
|
//
|
|
// npm run build && node scripts/wall-check.mjs
|
|
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/wall-check.mjs
|
|
//
|
|
// Playwright is not a dependency of this package (a browser run needs it, a
|
|
// build does not), so a run without it says SKIP and exits 0.
|
|
import { spawn } from 'node:child_process';
|
|
import { createServer } from 'node:http';
|
|
import { readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
|
const SAMPLE_FILE = process.env.SAMPLE_FILE ?? path.resolve(FRONTEND, '../sample/_3279791-develop-check.jpg');
|
|
const APP_PORT = Number(process.env.APP_PORT ?? 5033);
|
|
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034);
|
|
const FRAMES = Number(process.env.FRAMES ?? 300);
|
|
const FOLDER = 'WallRoll';
|
|
// Every frame is the same bytes under a different name: what is being checked is
|
|
// how many cards the wall will draw, not what is on them. The bytes carry no
|
|
// EXIF, so a frame's date is its own `lastModified` — spread over the days below,
|
|
// because a wall of one day is a wall with one header in it, and the headers are
|
|
// half of what this check is for.
|
|
const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`);
|
|
const DAY_MS = 86400000;
|
|
// Midday, so the day a frame is filed under on the reader's own clock is the day
|
|
// the check counts, whatever the box is set to.
|
|
const DAY0 = Date.UTC(2023, 0, 12, 12);
|
|
// The days the roll was shot on, and they are not equal: a roll is an afternoon
|
|
// of three frames and a wedding of four hundred, and the wall's own model of its
|
|
// rows is the thing that has to survive that. A shelf of equal days is a shelf a
|
|
// flat count is right about — `count / cols` rows of `cols` cards, with the step
|
|
// between them read off the run in hand standing for the rest — and a check of
|
|
// equal days says nothing about the shelf that is not. And the order matters as
|
|
// much as the sizes: the head of the roll is what a step read off the drawn rows
|
|
// comes from, so the roll is sixty days of two under four days of forty — and the
|
|
// head of it is a screen of bands with a frame or two under each, which is what
|
|
// puts a step taken as one average, over a band's row and a row of frames both,
|
|
// furthest out of true. (One small day among runs of a hundred and twenty was
|
|
// tried and does not show it; a first cut of this check missed the shape and had
|
|
// to be walked straight instead of jumped.)
|
|
const SMALL_DAY = 2;
|
|
const BIG_DAY = 40;
|
|
const DAY_PATTERN = [...Array(60).fill(SMALL_DAY), ...Array(4).fill(BIG_DAY)];
|
|
// The day a frame is filed under, off its own name — the same rule on both sides
|
|
// of the wire, so the check reads the wall the way the wall was told to sort it.
|
|
const dayIndex = (n) => {
|
|
let left = Math.max(0, n);
|
|
for (let cycle = 0; cycle < 1000; cycle++) {
|
|
for (let i = 0; i < DAY_PATTERN.length; i++) {
|
|
if (left < DAY_PATTERN[i]) return cycle * DAY_PATTERN.length + i;
|
|
left -= DAY_PATTERN[i];
|
|
}
|
|
}
|
|
return 0;
|
|
};
|
|
// The shooting days are laid out on the calendar rather than end to end: what the
|
|
// ruler beside the wall is read for is the months and the years of the roll, and
|
|
// a roll shot on consecutive days is a roll of one month and one tick. Eleven
|
|
// days between one shooting day and the next leaves it two years long with whole
|
|
// months nothing was shot in standing between them — and a month with no frame is
|
|
// a month with no tick, which is the ruler's own claim.
|
|
const DAY_STEP = 11;
|
|
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_STEP * DAY_MS;
|
|
const yearOf = (ts) => String(new Date(ts).getFullYear());
|
|
const monthOf = (ts) => {
|
|
const d = new Date(ts);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
};
|
|
const rollDays = names.map(dayOfName);
|
|
const rollYears = [...new Set(rollDays.map(yearOf))];
|
|
// The months the roll was shot in, in the shelf's own order: the ruler is a tick
|
|
// a month of the roll, and the months between them are the ones it skips.
|
|
const rollMonths = [...new Set(rollDays.map(monthOf))];
|
|
|
|
let failures = 0;
|
|
function step(name, ok, detail = '') {
|
|
if (!ok) failures++;
|
|
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
|
return ok;
|
|
}
|
|
|
|
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
|
|
if (!playwright) {
|
|
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
|
|
process.exit(0);
|
|
}
|
|
|
|
// --- the roll, off a local server (the page fetches it by CORS) --------------
|
|
const jpg = await readFile(SAMPLE_FILE);
|
|
const sampleServer = createServer((req, res) => {
|
|
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
|
|
if (!names.includes(name)) {
|
|
res.writeHead(404).end();
|
|
return;
|
|
}
|
|
res.writeHead(200, {
|
|
'Content-Type': 'image/jpeg',
|
|
'Content-Length': jpg.length,
|
|
'Access-Control-Allow-Origin': '*',
|
|
});
|
|
res.end(jpg);
|
|
});
|
|
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
|
|
|
|
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
|
|
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
|
|
// `BASE_URL` points the check at a build already being served instead of at
|
|
// `dist/`, which is how the bundle on the box is checked as it stands.
|
|
// `detached` so the whole group can be signalled at the end: `npx` spawns a shell
|
|
// which spawns vite, and killing only the wrapper leaves vite holding the port.
|
|
const app = process.env.BASE_URL
|
|
? null
|
|
: spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
|
cwd: FRONTEND,
|
|
stdio: 'ignore',
|
|
detached: true,
|
|
});
|
|
const stopApp = () => {
|
|
if (!app?.pid) return;
|
|
try {
|
|
process.kill(-app.pid, 'SIGTERM');
|
|
} catch {}
|
|
};
|
|
const base = process.env.BASE_URL ?? `http://127.0.0.1:${APP_PORT}`;
|
|
let up = !app;
|
|
for (let i = 0; i < 60 && !up; i++) {
|
|
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
|
|
if (!up) await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
if (!up) {
|
|
stopApp();
|
|
sampleServer.close();
|
|
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// --- the browser ------------------------------------------------------------
|
|
const browser = await playwright.chromium.launch({
|
|
executablePath: process.env.CHROME || undefined,
|
|
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
|
|
});
|
|
const context = await browser.newContext({ viewport: { width: 1500, height: 950 } });
|
|
|
|
// The account the shelf belongs to. `vite preview` has no backend, and the app
|
|
// reads a shape off every one of these calls, so the check answers them: an
|
|
// empty account is all the wall needs.
|
|
await context.route('**/api/**', (route) => {
|
|
const url = route.request().url();
|
|
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
|
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check', verified: true, pro: true, admin: false } });
|
|
if (url.includes('/photos')) return json({ photos: [] });
|
|
if (url.includes('/place')) return json({ place: 'Hanoi' });
|
|
if (url.includes('/ratings')) return json({ ratings: [] });
|
|
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
|
|
if (url.includes('/recipes')) return json({ recipes: [] });
|
|
return json({});
|
|
});
|
|
|
|
// Only a person can answer a folder picker; the check stands in for one. The
|
|
// stand-in handles carry their methods as *non-enumerable* properties, which is
|
|
// what lets them through IndexedDB's structured clone at all (a clone copies
|
|
// enumerable own keys, and a function anywhere else is a DataCloneError), and
|
|
// they are put back where the catalogue reads them, because a cloned handle is a
|
|
// plain object with no `getFile`.
|
|
await context.addInitScript(
|
|
({ origin, names, folder, days }) => {
|
|
// `days` is the day of each frame, index-aligned with `names`: the init
|
|
// script is cloned into the page and cannot close over the check's own scope.
|
|
const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1);
|
|
const fileHandle = (name) => {
|
|
const handle = { kind: 'file', name, __fake: true };
|
|
Object.defineProperty(handle, 'getFile', {
|
|
value: async () =>
|
|
new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, {
|
|
type: 'image/jpeg',
|
|
lastModified: dayOfNameIn(name),
|
|
}),
|
|
});
|
|
return handle;
|
|
};
|
|
const dirHandle = (label, node) => {
|
|
const handle = { kind: 'directory', name: label, __fake: true };
|
|
Object.defineProperties(handle, {
|
|
values: { value: () => [...node.files.map(fileHandle)][Symbol.iterator]() },
|
|
queryPermission: { value: async () => 'granted' },
|
|
requestPermission: { value: async () => 'granted' },
|
|
getDirectoryHandle: { value: async () => { throw new Error('no such folder'); } },
|
|
getFileHandle: {
|
|
value: async (name) => {
|
|
if (!node.files.includes(name)) throw new Error(`no frame named ${name}`);
|
|
return fileHandle(name);
|
|
},
|
|
},
|
|
});
|
|
return handle;
|
|
};
|
|
const root = () => dirHandle(folder, { files: names });
|
|
window.showDirectoryPicker = async () => root();
|
|
// The day each frame was given, for the header audit below: the page's own
|
|
// clock reads the label, so the day is read back here the same way.
|
|
window.__dayOf = (name) => dayOfNameIn(name);
|
|
|
|
const revive = (row) => {
|
|
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
|
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
|
|
}
|
|
return row;
|
|
};
|
|
for (const method of ['get', 'getAll']) {
|
|
const original = IDBObjectStore.prototype[method];
|
|
IDBObjectStore.prototype[method] = function (...args) {
|
|
const request = original.apply(this, args);
|
|
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
|
|
Object.defineProperty(request, 'result', {
|
|
configurable: true,
|
|
get() {
|
|
const value = descriptor.get.call(this);
|
|
return Array.isArray(value) ? value.map(revive) : revive(value);
|
|
},
|
|
});
|
|
return request;
|
|
};
|
|
}
|
|
},
|
|
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) }
|
|
);
|
|
|
|
const page = await context.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200)));
|
|
|
|
// The day a frame of this roll was shot on, on the reader's own clock — the same
|
|
// cut the wall files it under.
|
|
const dayKeyOf = (name) => {
|
|
const d = new Date(dayOfName(name));
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
|
|
// What the wall is drawing, read off the boxes: every row it has drawn, the frames
|
|
// in them, and the pane and body they are drawn in. The whole check is read off
|
|
// this, so a row standing at a top the layout did not give it is a row this sees.
|
|
const audit = () =>
|
|
page.evaluate(() => {
|
|
const el = document.querySelector('.adm-grid');
|
|
if (!el) return null;
|
|
const box = el.getBoundingClientRect();
|
|
const body = el.querySelector('.adm-wall-body');
|
|
const rows = [...el.querySelectorAll('.adm-row')].map((row) => {
|
|
const rb = row.getBoundingClientRect();
|
|
const month = row.querySelector('.adm-month-head');
|
|
return {
|
|
band: row.classList.contains('adm-band'),
|
|
day: row.getAttribute('data-day') ?? '',
|
|
label: row.querySelector('.adm-day-head')?.textContent?.trim() ?? '',
|
|
month: month ? (month.getAttribute('data-key') ?? '').replace('lib-month-', '') : '',
|
|
top: Math.round(rb.top - box.top),
|
|
bottom: Math.round(rb.bottom - box.top),
|
|
tiles: [...row.querySelectorAll('.lib-tile')].map((t) => {
|
|
const tb = t.getBoundingClientRect();
|
|
const img = t.querySelector('img');
|
|
return {
|
|
name: (t.getAttribute('data-key') ?? '').split('/').pop(),
|
|
// Kept as the browser laid them out: a row that is justified to the wall
|
|
// is a sum of widths, and rounding each of them is a sum off by up to
|
|
// half a frame a piece.
|
|
w: Number(tb.width.toFixed(2)),
|
|
h: Number(tb.height.toFixed(2)),
|
|
ar: img && img.naturalWidth > 0 ? Number((img.naturalWidth / img.naturalHeight).toFixed(3)) : null,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
const run = document.querySelector('.adm-row:not(.adm-band)');
|
|
return {
|
|
scrollTop: Math.round(el.scrollTop),
|
|
clientH: Math.round(el.clientHeight),
|
|
clientW: Math.round(el.clientWidth),
|
|
scrollH: el.scrollHeight,
|
|
bodyH: body ? Math.round(body.getBoundingClientRect().height) : null,
|
|
gap: run ? Number.parseFloat(getComputedStyle(run).columnGap) || 0 : 0,
|
|
rows,
|
|
};
|
|
});
|
|
|
|
// A stop on the shelf: a fraction of the room under it, or its own end at -1.
|
|
const scrollTo = (f) =>
|
|
page.evaluate((at) => {
|
|
const el = document.querySelector('.adm-grid');
|
|
el.scrollTop = at < 0 ? el.scrollHeight : Math.round((el.scrollHeight - el.clientHeight) * at);
|
|
el.dispatchEvent(new Event('scroll'));
|
|
}, f);
|
|
|
|
const read = () =>
|
|
page.evaluate(() => {
|
|
const el = document.querySelector('.adm-grid');
|
|
return {
|
|
tiles: el ? el.querySelectorAll('.lib-tile').length : 0,
|
|
// The white screen itself: no rail, no words, nothing drawn.
|
|
blank: document.body.innerText.trim().length < 40 && !document.querySelector('nav.rail'),
|
|
};
|
|
});
|
|
|
|
await page.goto(`${base}/library`);
|
|
await page.click('[data-key="lib-add"]');
|
|
await page.waitForSelector(`[data-key="lib-node-${FOLDER}"]`, { timeout: 60_000 });
|
|
await page
|
|
.waitForFunction(
|
|
(n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)),
|
|
FRAMES,
|
|
{ timeout: 240_000, polling: 250 }
|
|
)
|
|
.catch(() => {});
|
|
const counted = await page.$eval('[data-key="lib-count"]', (e) => e.textContent?.trim() ?? '');
|
|
step('the roll is read whole', counted.includes(String(FRAMES)), counted);
|
|
|
|
await page.click('[data-key="lib-view-grid"]');
|
|
await page.waitForSelector('.adm-row', { timeout: 15_000 });
|
|
await page.waitForFunction(() => document.querySelectorAll('.lib-tile').length > 0, null, { timeout: 30_000 });
|
|
await page.waitForTimeout(2500);
|
|
|
|
const opened = await audit();
|
|
const openedTiles = opened.rows.reduce((n, r) => n + r.tiles.length, 0);
|
|
step('the wall draws a screenful of the shelf', openedTiles > 0 && openedTiles < FRAMES, `${openedTiles} of ${FRAMES} frames drawn`);
|
|
step('and stands in for the rows it is not drawing', opened.scrollH > opened.clientH, `a shelf of ${opened.scrollH}px in a pane of ${opened.clientH}px`);
|
|
// The shelf's own height, which is the layout's and not the DOM's: a body standing
|
|
// shorter than the layout puts the frames under the fold out of reach, and one
|
|
// standing taller leaves the reader at the end of the scroll with a screen of
|
|
// nothing under the last frame drawn.
|
|
step(
|
|
'the body of the wall stands the shelf the layout counted',
|
|
opened.bodyH !== null && Math.abs(opened.scrollH - opened.bodyH) <= 1,
|
|
`body ${opened.bodyH}px, scroll ${opened.scrollH}px`
|
|
);
|
|
|
|
// The two frames the pane is drawn for, and the ones a shelf that moves under the
|
|
// reader hides: the row over the top edge of the pane and the row under its bottom
|
|
// edge are rows the wall has to have drawn, at every stop of the roll, its two
|
|
// ends included. Read down the shelf and not at one stop, since a stop of any one
|
|
// fraction lands inside a day often as it lands on a fold.
|
|
const stops = [0, 0.15, 0.35, 0.55, 0.75, 0.95, -1];
|
|
// Read down the drawn rows, from the top edge of the pane to its bottom one, and
|
|
// the holes between them: two rows of the wall stand a gap apart, and a gap is
|
|
// not a hole — anything wider than the gap is a row the window left out.
|
|
const holes = [];
|
|
const SLIT = 4;
|
|
const unjustified = [];
|
|
const dayBad = [];
|
|
const seenDays = new Set();
|
|
for (const at of stops) {
|
|
await scrollTo(at);
|
|
await page.waitForTimeout(900);
|
|
const a = await audit();
|
|
let cursor = 0;
|
|
for (const r of [...a.rows].sort((x, y) => x.top - y.top)) {
|
|
if (r.bottom <= 0) continue;
|
|
if (r.top > cursor + SLIT) holes.push({ at, slit: [cursor, r.top] });
|
|
if (r.bottom > cursor) cursor = r.bottom;
|
|
}
|
|
if (cursor < a.clientH - 1) holes.push({ at, open: [cursor, a.clientH] });
|
|
// A row of frames is justified to the wall: the frames of a day packed into a row
|
|
// until it is at least as wide as the wall, and the row then stretched to exactly
|
|
// that width, so every frame keeps its own proportions and meets its neighbours.
|
|
// Every row of a day but the last is that width; the last one is as wide as the
|
|
// day's frames come out, which is what tells one day from the next.
|
|
const run = a.rows.filter((r) => !r.band);
|
|
for (let i = 0; i < run.length; i++) {
|
|
const row = run[i];
|
|
if (!row.tiles.length) continue;
|
|
const sum = row.tiles.reduce((n, t) => n + t.w, 0) + a.gap * (row.tiles.length - 1);
|
|
const last = i + 1 >= run.length || run[i + 1].day !== row.day;
|
|
if (Math.abs(sum - a.clientW) > 1 && !(last && sum < a.clientW)) {
|
|
unjustified.push({ day: row.day, sum, wall: a.clientW, last });
|
|
}
|
|
for (const t of row.tiles) {
|
|
if (t.ar !== null && Math.abs(t.w / t.h - t.ar) > 0.02) {
|
|
unjustified.push({ day: row.day, name: t.name, ar: t.ar, drawn: Number((t.w / t.h).toFixed(3)) });
|
|
}
|
|
}
|
|
}
|
|
// And the bands: a band names the day it stands over, and the frames under it are
|
|
// the frames of that day.
|
|
let head = null;
|
|
for (const row of a.rows) {
|
|
if (row.band) {
|
|
head = row.day;
|
|
if (head) seenDays.add(head);
|
|
continue;
|
|
}
|
|
// The first row drawn may begin under a band the window left above the fold.
|
|
if (!head) continue;
|
|
if (row.day !== head) dayBad.push({ head, row: row.day });
|
|
for (const t of row.tiles) {
|
|
if (t.name && head !== dayKeyOf(t.name)) dayBad.push({ head, name: t.name, own: dayKeyOf(t.name) });
|
|
}
|
|
}
|
|
}
|
|
step('the frames fill the pane from its top edge to its bottom one', holes.length === 0, JSON.stringify(holes.slice(0, 2)));
|
|
step('a row of frames is justified to the wall, and a day ends in a row of its own', unjustified.length === 0, JSON.stringify(unjustified.slice(0, 2)));
|
|
step(
|
|
'every row of frames stands under the band that names its day',
|
|
dayBad.length === 0 && seenDays.size > 1,
|
|
`${seenDays.size} days through the window, ${JSON.stringify(dayBad.slice(0, 2))}`
|
|
);
|
|
|
|
// Back to the head of the roll, where the month's own band is drawn.
|
|
await scrollTo(0);
|
|
await page.waitForTimeout(1200);
|
|
|
|
// The two bands, at the heights the wall lays its shelf out in: both are fixed
|
|
// numbers in the style sheet, and the shelf's whole height — and with it every
|
|
// frame's own top — is counted from them, so a band that grew with its own text is
|
|
// a shelf whose end moves.
|
|
const bands = await page.evaluate(() => {
|
|
const h = (sel) => {
|
|
const el = document.querySelector(sel);
|
|
return el ? Math.round(el.getBoundingClientRect().height) : null;
|
|
};
|
|
return { month: h('.adm-month-head'), day: h('.adm-day-head') };
|
|
});
|
|
step('the month and the day bands stand at the heights the wall lays them in', bands.month === 44 && bands.day === 30, JSON.stringify(bands));
|
|
|
|
// The month's own band, over the first day of the month it names and over no
|
|
// other: the wall is read by the month it was shot and only then by the day, and a
|
|
// band left standing over a day of the month above it is a month named twice.
|
|
const head = await audit();
|
|
const monthBad = [];
|
|
let prev = null;
|
|
let monthBands = 0;
|
|
for (const row of head.rows) {
|
|
if (!row.band) continue;
|
|
const month = row.day.slice(0, 7);
|
|
if (row.month) monthBands++;
|
|
// The window's own first day is not read: it is a day the wall began in the
|
|
// middle of, and the band above it was drawn above the fold or not at all.
|
|
if (prev !== null && ((month !== prev) !== Boolean(row.month) || (row.month && row.month !== month))) {
|
|
monthBad.push({ day: row.day, named: row.month, month });
|
|
}
|
|
prev = month;
|
|
}
|
|
step('the wall names a month over the first day of it, and only there', monthBands >= 1 && monthBad.length === 0, `${monthBands} month bands, ${JSON.stringify(monthBad.slice(0, 2))}`);
|
|
|
|
// The ruler down the right edge: a tick a month of the roll and a named tick a
|
|
// year of it, on the shelf's own months and not on a calendar — a month nothing
|
|
// was shot in is a month with no tick.
|
|
const ticks = await page.evaluate(() => ({
|
|
name: document.querySelector('.lib-ruler-name')?.textContent?.trim() ?? '',
|
|
years: [...document.querySelectorAll('.lib-ruler-tick[data-kind="year"]')].map((e) => (e.textContent ?? '').trim()),
|
|
months: [...document.querySelectorAll('.lib-ruler-tick[data-kind="month"]')].map((e) => e.getAttribute('data-at')),
|
|
}));
|
|
step('the ruler stands beside the wall', ticks.name.length > 0, ticks.name);
|
|
step('a tick a month of the roll', ticks.months.length === rollMonths.length, `${ticks.months.length} of ${rollMonths.length} months`);
|
|
step('and a named tick a year of it', [...ticks.years].sort().join() === [...rollYears].sort().join(), `${ticks.years.join()} of ${rollYears.join()}`);
|
|
|
|
// Pressing the ruler: a month's tick brings the band of that month to the top of
|
|
// the wall, which is a month the wall had never drawn — the whole reason the
|
|
// shelf's own layout is counted rather than measured off the boxes in the DOM.
|
|
const press = await page.evaluate(() => {
|
|
const list = [...document.querySelectorAll('.lib-ruler-tick[data-kind="month"]')];
|
|
const mid = list[Math.floor(list.length / 2)];
|
|
const tick = mid.getAttribute('data-key') ?? '';
|
|
return { tick, key: tick.replace('lib-ruler-', ''), at: Number(mid.getAttribute('data-at')) };
|
|
});
|
|
await page.click(`[data-key="${press.tick}"]`);
|
|
await page.waitForTimeout(1500);
|
|
const landed = await page.evaluate((key) => {
|
|
const grid = document.querySelector('.adm-grid');
|
|
const band = document.querySelector(`[data-key="lib-month-${key}"]`);
|
|
const on = document.querySelector('.lib-ruler-tick.on');
|
|
return {
|
|
drawn: Boolean(band),
|
|
offset: band && grid ? Math.round(band.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
|
|
value: document.querySelector('[data-key="lib-ruler-value"]')?.textContent?.trim() ?? '',
|
|
on: on ? on.getAttribute('data-at') : '',
|
|
};
|
|
}, press.key);
|
|
step('a press on the ruler brings that month to the top', landed.drawn && landed.offset !== null && landed.offset >= -2 && landed.offset <= 18, `band at ${landed.offset}px`);
|
|
step(
|
|
'and the ruler reads the month it was taken to',
|
|
Number(landed.on) === press.at && landed.value.length > 0,
|
|
`on=${landed.on} of ${press.at}, " ${landed.value}"`
|
|
);
|
|
|
|
// A notch of the wheel, over the ruler: one month, whichever way the roll is read,
|
|
// and never off the end of it.
|
|
const notch = () =>
|
|
page.evaluate(() => {
|
|
const months = [...document.querySelectorAll('.lib-ruler-tick')].filter((t) => t.getAttribute('data-kind') === 'month');
|
|
const on = months.find((t) => t.classList.contains('on'));
|
|
const needle = document.querySelector('[data-key="lib-ruler-needle"]');
|
|
return {
|
|
on: months.findIndex((t) => t.classList.contains('on')),
|
|
count: months.length,
|
|
needle: needle ? Number.parseFloat(needle.style.top) || 0 : 0,
|
|
tick: on ? Number.parseFloat(on.style.top) || 0 : 0,
|
|
};
|
|
});
|
|
const beforeNotch = await notch();
|
|
await page.evaluate(() => {
|
|
const el = document.querySelector('[data-key="lib-ruler"]');
|
|
el.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
|
|
});
|
|
await page.waitForTimeout(1500);
|
|
const afterNotch = await notch();
|
|
step('a notch of the wheel is one month', afterNotch.on === beforeNotch.on + 1, `${beforeNotch.on} -> ${afterNotch.on} of ${beforeNotch.count}`);
|
|
step('and the needle stands in the month the eye is in', Math.abs(afterNotch.needle - afterNotch.tick) < 0.6, `needle ${afterNotch.needle}% of tick ${afterNotch.tick}%`);
|
|
|
|
// The shelf walked to its own end, a screen at a time — a jump straight to the
|
|
// bottom is a different question, and the one this check exists for: the wall
|
|
// draws the rows around the eye and stands in for the rest with its own height, so
|
|
// a body standing shorter than the layout leaves the reader at the end of the
|
|
// scroll on a screen of nothing and the rest of the roll never drawn at all. What
|
|
// the walk is read for is that it *gets* there, standing on the last frame, and
|
|
// that every frame of the roll was drawn on the way.
|
|
const walked = await page.evaluate(async () => {
|
|
const el = document.querySelector('.adm-grid');
|
|
const seen = new Set();
|
|
const collect = () => {
|
|
for (const tile of el.querySelectorAll('.lib-tile')) seen.add(tile.getAttribute('data-key'));
|
|
};
|
|
const breathe = () => new Promise((r) => setTimeout(r, 150));
|
|
el.scrollTop = 0;
|
|
await breathe();
|
|
let guard = 0;
|
|
while (guard++ < 400) {
|
|
collect();
|
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 2) break;
|
|
el.scrollTop = el.scrollTop + Math.round(el.clientHeight * 0.8);
|
|
await breathe();
|
|
}
|
|
collect();
|
|
const body = el.querySelector('.adm-wall-body');
|
|
const tiles = [...el.querySelectorAll('.lib-tile')];
|
|
const last = tiles[tiles.length - 1];
|
|
return {
|
|
top: Math.round(el.scrollTop),
|
|
reached: el.scrollTop + el.clientHeight >= el.scrollHeight - 2,
|
|
room: Math.round(el.scrollHeight - el.clientHeight),
|
|
// What is left under the last frame drawn: the body that has nothing left to
|
|
// stand in for.
|
|
gap: last && body ? Math.round(body.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom) : null,
|
|
seen: [...seen],
|
|
};
|
|
});
|
|
step('the shelf is walked to its own end', walked.reached, `stopped at ${walked.top} of ${walked.room}`);
|
|
step('and stands on nothing there', walked.gap !== null && walked.gap <= 2, `${walked.gap}px of nothing under the last frame`);
|
|
const missed = names.filter((name) => !walked.seen.includes(`lib-photo-${FOLDER}/${name}`));
|
|
step('every frame of the roll is drawn on the way', missed.length === 0, `${walked.seen.length} of ${names.length} drawn, first missed ${missed[0] ?? 'none'}`);
|
|
|
|
// The class of trigger, made to happen: one input the wall reads back off itself,
|
|
// moving on every pass.
|
|
await page.evaluate(() => {
|
|
const el = document.querySelector('.adm-grid');
|
|
const h = el.clientHeight;
|
|
let n = 0;
|
|
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => (n++ % 2 ? h : h + 300) });
|
|
el.dispatchEvent(new Event('scroll'));
|
|
});
|
|
await page.waitForTimeout(8000);
|
|
const after = await read();
|
|
const looped = errors.filter((e) => e.includes('185') || e.includes('update depth'));
|
|
step(
|
|
'a wall fed its own window does not take the screen down',
|
|
!after.blank && after.tiles > 0 && looped.length === 0,
|
|
`blank=${after.blank}, tiles=${after.tiles}, errors=${looped.length ? looped[0] : 'none'}`
|
|
);
|
|
step('the wall is still the wall', after.tiles > 0, `${after.tiles} frames after the feed`);
|
|
|
|
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
|
|
if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // '));
|
|
|
|
await browser.close();
|
|
stopApp();
|
|
sampleServer.close();
|
|
console.log(failures ? `\n${failures} FAILED` : '\nall ok');
|
|
process.exit(failures ? 1 : 0);
|