557 lines
25 KiB
JavaScript
557 lines
25 KiB
JavaScript
// The wall, checked where it can actually go wrong: in a browser, on a shelf long
|
|
// enough that a day of it is a screen and the shelf is many screens of them.
|
|
//
|
|
// What it guards is the wall *being* the shelf, and saying so: every frame the open
|
|
// node holds is a card (the wall is drawn whole — no window over it, so no card
|
|
// arriving late and no spacer standing in for one), every card sits under the band
|
|
// of its own day, the last frame is reachable by scrolling with nothing left under
|
|
// it, and the rail down the wall's right edge — the shelf's own time — reads the
|
|
// day the eye is in and takes the wall to the day it is pressed for.
|
|
//
|
|
// It is drawn whole for a reason this check keeps an eye on: the wall used to be a
|
|
// window over the shelf, and a window measured off the window before it cost a
|
|
// white screen once (React #185, no error boundary under it). None of that state is
|
|
// left — but a resize under the wall's own eye is the same class of input, and the
|
|
// wall re-columns and the rail re-reads through it without taking the screen down.
|
|
//
|
|
// 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';
|
|
// The second roll: a shelf of one month, which the rail reads by the day rather
|
|
// than by the month — the other half of what the rail is (`rail` in Library.tsx),
|
|
// and the shape a folder per event has. Its frames are two days of twenty.
|
|
const SMALL_FOLDER = 'OneDayRoll';
|
|
const SMALL_EACH = 20;
|
|
const SMALL_DAYS = 2;
|
|
// 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;
|
|
const DAY0 = Date.UTC(2026, 0, 1);
|
|
// 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;
|
|
};
|
|
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_MS;
|
|
|
|
// The second roll's own names and days: `one-0001` onwards, twenty to a day, over
|
|
// the fourth and fifth of March 2026 — one month, two days, which is the shelf the
|
|
// rail reads by the day.
|
|
const smallNames = Array.from(
|
|
{ length: SMALL_DAYS * SMALL_EACH },
|
|
(_, i) => `one-${String(i + 1).padStart(4, '0')}.jpg`
|
|
);
|
|
const smallDayOfName = (name) =>
|
|
Date.UTC(2026, 2, 4 + Math.floor((Number((name.match(/\d+/) ?? ['1'])[0]) - 1) / SMALL_EACH));
|
|
const allNames = [...names, ...smallNames];
|
|
// What the tree is asked for and the sample server serves: both rolls.
|
|
const servedNames = new Set(allNames);
|
|
// Which of the two rolls a name belongs to: the big one is the shelf this check
|
|
// walks, the small one is a shelf of a single month.
|
|
const dayOf = (name) => (smallNames.includes(name) ? smallDayOfName(name) : dayOfName(name));
|
|
// The months of the big roll, and the days of the small one: what the rail has to
|
|
// hold, read off the roll rather than written down twice.
|
|
const monthOf = (ts) => {
|
|
const d = new Date(ts);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
};
|
|
const keyOf = (ts) => {
|
|
const d = new Date(ts);
|
|
return `${monthOf(ts)}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
const MONTHS = [...new Set(names.map((name) => monthOf(dayOf(name))))];
|
|
const SMALL_DAY_KEYS = [...new Set(smallNames.map((name) => keyOf(dayOf(name))))];
|
|
// The days the big roll is cut into, which is what the wall draws bands for.
|
|
const BIG_DAYS = (() => {
|
|
let day = 0;
|
|
for (let i = 0; i < FRAMES; i += 1) {
|
|
if (!i || dayOfName(names[i]) !== dayOfName(names[i - 1])) day += 1;
|
|
}
|
|
return day;
|
|
})();
|
|
|
|
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 (!servedNames.has(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, smallNames, smallFolder, smallDays }) => {
|
|
// `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) => {
|
|
const at = names.indexOf(name);
|
|
return at >= 0 ? days[at] : smallDays[smallNames.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;
|
|
};
|
|
// The folder the reader picks: the first press is the shelf this check walks,
|
|
// the second is the shelf of a single month (the rail's other reading). A
|
|
// picker is one folder at a time, and a check that needs two picks is a check
|
|
// that hands them over one after the other.
|
|
const root = (label) =>
|
|
label === smallFolder ? dirHandle(smallFolder, { files: smallNames }) : dirHandle(folder, { files: names });
|
|
let picked = 0;
|
|
window.showDirectoryPicker = async () => root(picked++ === 0 ? folder : smallFolder);
|
|
// 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(row.handle.name) : 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),
|
|
smallNames,
|
|
smallFolder: SMALL_FOLDER,
|
|
smallDays: smallNames.map(smallDayOfName),
|
|
}
|
|
);
|
|
|
|
const page = await context.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200)));
|
|
const read = () =>
|
|
page.evaluate(() => {
|
|
const el = document.querySelector('.adm-grid');
|
|
return {
|
|
cards: el ? el.querySelectorAll('.adm-card').length : 0,
|
|
wall: el ? el.scrollHeight > el.clientHeight : false,
|
|
// 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);
|
|
|
|
// The day the header of a card stands under, read off the sections themselves:
|
|
// every header must name the day of every card filed under it. A day whose first
|
|
// rows are above the window still gets its header where the eye lands (see
|
|
// `dayRuns`), so the rule is "a header names its own cards" and not "one header
|
|
// per day in the shelf".
|
|
const dayAudit = () =>
|
|
page.evaluate(() => {
|
|
const key = (ts) => {
|
|
const d = new Date(ts);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
const out = { sections: 0, cards: 0, labelled: 0, bad: [] };
|
|
for (const section of document.querySelectorAll('.adm-day')) {
|
|
out.sections++;
|
|
const own = (section.getAttribute('data-key') ?? '').replace('lib-day-', '');
|
|
const head = section.querySelector('.adm-day-head')?.textContent?.trim() ?? '';
|
|
if (head) out.labelled++;
|
|
const names = [...section.querySelectorAll('[data-key^="lib-photo-"]')].map(
|
|
(el) => (el.getAttribute('data-key') ?? '').split('/').pop()
|
|
);
|
|
const days = new Set(names.map((name) => key(window.__dayOf(name))));
|
|
if (!names.length || days.size !== 1 || !days.has(own)) out.bad.push({ own, head, days: [...days], names: names.slice(0, 2) });
|
|
}
|
|
out.cards = document.querySelectorAll('.adm-card').length;
|
|
return out;
|
|
});
|
|
|
|
// --- the wall, drawn whole ---------------------------------------------------
|
|
await page.click('[data-key="lib-view-grid"]');
|
|
await page.waitForSelector('.adm-grid', { timeout: 15_000 });
|
|
// The wall is the shelf: what the open node holds is what has to be on it. A window
|
|
// over the shelf is a shelf that tears itself open under the reader — a day drawn a
|
|
// couple of rows deep, growing as the eye walks down it, and a frame scrolled back
|
|
// up to built again — so this is the first thing read.
|
|
await page.waitForFunction(
|
|
(n) => document.querySelectorAll('.adm-grid .adm-card').length >= n,
|
|
FRAMES,
|
|
{ timeout: 120_000, polling: 250 }
|
|
);
|
|
await page.waitForTimeout(2000);
|
|
const wall = await read();
|
|
step('the wall draws every frame of the roll', wall.cards === FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
|
|
step('and it is a shelf that scrolls', wall.wall, 'the wall scrolls');
|
|
|
|
const days1 = await dayAudit();
|
|
step('the wall draws a band for every day of the roll', days1.sections === BIG_DAYS, `${days1.sections} days of ${BIG_DAYS}`);
|
|
step('every header names the day of the cards under it', days1.bad.length === 0, JSON.stringify(days1.bad.slice(0, 2)));
|
|
|
|
// What the rail is read for: the entries it holds, the entry the eye is in, and where
|
|
// a press on one takes the wall. The day at the fold is read off the sections
|
|
// themselves — the first one crossing the wall's top band, which is the rule the rail
|
|
// itself reads by — and the entry the wall says it is in is read off the accent.
|
|
const railOf = () =>
|
|
page.evaluate(() => {
|
|
const grid = document.querySelector('.adm-grid');
|
|
const rail = document.querySelector('.lib-rail');
|
|
const items = [...(rail?.querySelectorAll('.lib-rail-item') ?? [])];
|
|
const top = grid.getBoundingClientRect().top;
|
|
const band = top + grid.clientHeight * 0.12;
|
|
let fold = null;
|
|
for (const section of grid.querySelectorAll('.adm-day')) {
|
|
const box = section.getBoundingClientRect();
|
|
if (box.top <= band && box.bottom > top) {
|
|
fold = (section.getAttribute('data-key') ?? '').replace('lib-day-', '');
|
|
break;
|
|
}
|
|
}
|
|
const on = document.querySelector('.lib-rail-item.on');
|
|
return {
|
|
keys: items.map((b) => (b.getAttribute('data-key') ?? '').replace('lib-rail-', '')),
|
|
labels: items.map((b) => b.textContent.trim()),
|
|
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
|
|
fold,
|
|
scroll: Math.round(rail?.scrollTop ?? 0),
|
|
tops: items.map((b) => b.offsetTop),
|
|
heights: items.map((b) => b.offsetHeight),
|
|
box: rail?.clientHeight ?? 0,
|
|
};
|
|
});
|
|
const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
|
|
const seenMonths = new Set();
|
|
let scrolledBad = [];
|
|
let railBad = [];
|
|
for (const at of stops) {
|
|
await page.evaluate((f) => {
|
|
const el = document.querySelector('.adm-grid');
|
|
el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) * f);
|
|
el.dispatchEvent(new Event('scroll'));
|
|
}, at);
|
|
await page.waitForTimeout(900);
|
|
const audit = await dayAudit();
|
|
scrolledBad = scrolledBad.concat(audit.bad);
|
|
const rail = await railOf();
|
|
// The entry the rail says the eye is in has to own the day at the fold — the month
|
|
// its key begins with — and the entry has to be inside the rail's own box: a rail
|
|
// read for the whole shelf and standing on its own top is a rail that says nothing.
|
|
if (!rail.fold || !rail.on || rail.fold.slice(0, 7) !== rail.on.slice(0, 7)) {
|
|
railBad.push({ at, fold: rail.fold, on: rail.on });
|
|
} else {
|
|
seenMonths.add(rail.on.slice(0, 7));
|
|
const i = rail.keys.indexOf(rail.on);
|
|
const y = (rail.tops[i] ?? 0) - rail.scroll;
|
|
if (y < -2 || y + (rail.heights[i] ?? 0) > rail.box + 2) railBad.push({ at, on: rail.on, y, box: rail.box });
|
|
}
|
|
}
|
|
step(
|
|
'a scrolled wall still files every card under its own day',
|
|
scrolledBad.length === 0,
|
|
JSON.stringify(scrolledBad.slice(0, 2))
|
|
);
|
|
const rail1 = await railOf();
|
|
const railMonths = [...new Set(rail1.keys.map((k) => k.slice(0, 7)))].sort();
|
|
step('the rail holds the months of the shelf', railMonths.join() === [...MONTHS].sort().join(), `${rail1.keys.join(' ')}`);
|
|
step(
|
|
'and the entry it reads follows the eye down the shelf',
|
|
railBad.length === 0 && seenMonths.size === MONTHS.length,
|
|
`${seenMonths.size} of ${MONTHS.length} months read, ${JSON.stringify(railBad.slice(0, 2))}`
|
|
);
|
|
|
|
// And the press: the month's own first day is where the wall has to stand, its header
|
|
// just under the wall's top edge, and the entry pressed is the one read afterwards.
|
|
const jumped = rail1.keys[1];
|
|
await page.click(`[data-key="lib-rail-${jumped}"]`);
|
|
await page.waitForTimeout(700);
|
|
const landed = await page.evaluate((key) => {
|
|
const grid = document.querySelector('.adm-grid');
|
|
const section = grid.querySelector(`[data-key="lib-day-${key}"]`);
|
|
const box = grid.getBoundingClientRect();
|
|
const on = document.querySelector('.lib-rail-item.on');
|
|
return {
|
|
top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null,
|
|
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
|
|
};
|
|
}, jumped);
|
|
step(
|
|
'a press on a month takes the wall to that day',
|
|
landed.top !== null && Math.abs(landed.top - 6) <= 6 && landed.on === jumped,
|
|
`header at ${landed.top}, entry ${landed.on}`
|
|
);
|
|
|
|
// The shelf walked to its own end, a screen at a time — a jump straight to the bottom
|
|
// is a different question, and this is the one that stops a reader at a screen of
|
|
// nothing: what the walk is read for is that it *gets* there, standing on the last
|
|
// frame with no room left under it.
|
|
const walked = await page.evaluate(async () => {
|
|
const el = document.querySelector('.adm-grid');
|
|
const seen = new Set();
|
|
const collect = () => {
|
|
for (const card of el.querySelectorAll('.adm-card')) seen.add(card.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 cards = [...el.querySelectorAll('.adm-card')];
|
|
const last = cards[cards.length - 1];
|
|
const box = el.getBoundingClientRect();
|
|
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 card drawn. A few pixels is the grid's own gap.
|
|
gap: last ? Math.round(el.scrollHeight - (last.getBoundingClientRect().bottom - box.top + el.scrollTop)) : null,
|
|
};
|
|
});
|
|
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 <= 16, `${walked.gap}px of nothing under the last card`);
|
|
|
|
// A wall resized under its own eye: the class of input that cost the screen once, when
|
|
// the wall measured itself off itself. It re-columns, the rail re-reads, and the reader
|
|
// is still standing in the library after it.
|
|
await page.setViewportSize({ width: 1180, height: 820 });
|
|
await page.waitForTimeout(700);
|
|
await page.setViewportSize({ width: 1500, height: 950 });
|
|
await page.waitForTimeout(700);
|
|
const after = await read();
|
|
const looped = errors.filter((e) => e.includes('185') || e.includes('update depth'));
|
|
step(
|
|
'a wall resized under its own eye does not take the screen down',
|
|
!after.blank && after.cards === FRAMES && looped.length === 0,
|
|
`blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}`
|
|
);
|
|
const rail2 = await railOf();
|
|
step(
|
|
'and its rail still reads the day at the fold',
|
|
rail2.on !== null && rail2.fold !== null && rail2.fold.slice(0, 7) === rail2.on.slice(0, 7),
|
|
`fold ${rail2.fold}, entry ${rail2.on}`
|
|
);
|
|
|
|
// The rail's other reading: a shelf of a single month is a shelf read by the day. The
|
|
// second folder picked is that shelf — two days of twenty frames — and the wall on it
|
|
// is a wall of two bands.
|
|
await page.click('[data-key="lib-add"]');
|
|
await page.waitForSelector(`[data-key="lib-node-${SMALL_FOLDER}"]`, { timeout: 60_000 });
|
|
await page.click(`[data-key="lib-node-${SMALL_FOLDER}"]`);
|
|
await page.waitForFunction(
|
|
(n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)),
|
|
smallNames.length,
|
|
{ timeout: 240_000, polling: 250 }
|
|
);
|
|
await page.click('[data-key="lib-view-grid"]');
|
|
await page.waitForTimeout(1500);
|
|
const rail3 = await railOf();
|
|
step(
|
|
'a shelf of one month is read by the day',
|
|
[...rail3.keys].sort().join() === [...SMALL_DAY_KEYS].sort().join() && rail3.keys.length === SMALL_DAY_KEYS.length,
|
|
`${rail3.labels.join(' ')} / ${rail3.keys.join(' ')}`
|
|
);
|
|
const plainDay = SMALL_DAY_KEYS[0];
|
|
await page.click(`[data-key="lib-rail-${plainDay}"]`);
|
|
await page.waitForTimeout(700);
|
|
const small = await page.evaluate((key) => {
|
|
const grid = document.querySelector('.adm-grid');
|
|
const section = grid.querySelector(`[data-key="lib-day-${key}"]`);
|
|
const box = grid.getBoundingClientRect();
|
|
const on = document.querySelector('.lib-rail-item.on');
|
|
return {
|
|
top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null,
|
|
cards: section ? section.querySelectorAll('.adm-card').length : 0,
|
|
on: on?.textContent.trim() ?? null,
|
|
};
|
|
}, plainDay);
|
|
step(
|
|
'a press on a day takes the wall to it, whole',
|
|
small.top !== null &&
|
|
Math.abs(small.top - 6) <= 6 &&
|
|
small.cards === SMALL_EACH &&
|
|
small.on === String(Number(plainDay.slice(8, 10))),
|
|
`header at ${small.top}, ${small.cards} cards, entry ${small.on}`
|
|
);
|
|
|
|
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);
|