feat(library): cut the wall by month and day, and stand a ruler timeline on its right edge

This commit is contained in:
2026-10-13 10:37:57 +07:00
parent f8cac67506
commit 822833c1ef
6 changed files with 1044 additions and 626 deletions
+189 -281
View File
@@ -1,23 +1,17 @@
// 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.
// enough that the wall is drawing a few of its rows and standing in for the rest.
//
// 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, by the year
// and the month and nothing finer — scrolls under the reader's hand, reads the month
// the eye is in and takes the wall to the month or the year it is pressed for.
// 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 wall is also the one screen that reads the whole shelf, so what it mints is
// watched here too: a roll of years is a rail of months, and a rail whose every entry
// is in the box it is read through.
//
// 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.
// 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
@@ -36,12 +30,6 @@ 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 a single month, which the rail reads with one head and
// one month under it — 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,
@@ -49,11 +37,9 @@ const SMALL_DAYS = 2;
// 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;
// The roll is spread over years, a shooting day every forty days or so: a shelf of
// months is what the rail is for, and a rail that holds three of them is a rail that
// never has to be scrolled — the years are what make it longer than the wall is tall.
const DAY_SPREAD = 40;
const DAY0 = Date.UTC(2018, 0, 1);
// 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
@@ -82,45 +68,24 @@ const dayIndex = (n) => {
}
return 0;
};
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_SPREAD * 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.
// 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 keyOf = (ts) => {
const d = new Date(ts);
return `${monthOf(ts)}-${String(d.getDate()).padStart(2, '0')}`;
};
const yearOf = (ts) => String(new Date(ts).getFullYear());
const MONTHS = [...new Set(names.map((name) => monthOf(dayOf(name))))];
const YEARS = [...new Set(names.map((name) => yearOf(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;
})();
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 = '') {
@@ -139,7 +104,7 @@ if (!playwright) {
const jpg = await readFile(SAMPLE_FILE);
const sampleServer = createServer((req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (!servedNames.has(name)) {
if (!names.includes(name)) {
res.writeHead(404).end();
return;
}
@@ -213,13 +178,10 @@ await context.route('**/api/**', (route) => {
// 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 }) => {
({ 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) => {
const at = names.indexOf(name);
return at >= 0 ? days[at] : smallDays[smallNames.indexOf(name)] ?? Date.UTC(2026, 0, 1);
};
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', {
@@ -247,21 +209,15 @@ await context.addInitScript(
});
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);
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(row.handle.name) : fileHandle(row.handle.name);
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
}
return row;
};
@@ -281,15 +237,7 @@ await context.addInitScript(
};
}
},
{
origin: `http://127.0.0.1:${SAMPLE_PORT}`,
names,
folder: FOLDER,
days: names.map(dayOfName),
smallNames,
smallFolder: SMALL_FOLDER,
smallDays: smallNames.map(smallDayOfName),
}
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) }
);
const page = await context.newPage();
@@ -346,69 +294,31 @@ const dayAudit = () =>
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.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 });
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');
step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
step('and stands in for the rows it is not drawing', wall.wall, 'the shelf 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}`);
// The first screenful is one day of a mixed roll often as not — a day big enough
// to fill it is a day at all — so what is read here is that a band stands over
// the cards and names its day, and the cut into several days is read below, on a
// wall scrolled off the fold.
step('the wall draws its days over the cards', days1.sections >= 1 && days1.labelled === days1.sections, `${days1.sections} headers over ${days1.cards} cards`);
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 month 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 {
kinds: items.map((b) => b.getAttribute('data-kind') ?? ''),
keys: items.map((b) => (b.getAttribute('data-key') ?? '').replace('lib-rail-', '')),
// The day a press on the entry sends the wall to, which the entry carries.
ats: items.map((b) => b.getAttribute('data-at') ?? ''),
labels: items.map((b) => b.textContent.trim()),
onKind: on?.getAttribute('data-kind') ?? null,
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,
whole: rail?.scrollHeight ?? 0,
};
});
const monthsOf = (rail) => rail.keys.filter((_, i) => rail.kinds[i] === 'month');
const yearsOf = (rail) => rail.keys.filter((_, i) => rail.kinds[i] === 'year');
// The same wall, scrolled to where no day starts at the fold: the headers have to
// move with it, which is the whole risk of cutting a virtualised wall into days.
// Read down the shelf rather than at one stop: a stop of any one fraction lands
// inside a day big enough to fill the screen as often as it lands on a fold, so
// what is read is that no band ever files a card under another day's header, and
// that the shelf's own cuts move through the window as it travels.
const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
const seenMonths = new Set();
const seenDays = new Set();
let scrolledBad = [];
let railBad = [];
for (const at of stops) {
await page.evaluate((f) => {
const el = document.querySelector('.adm-grid');
@@ -417,98 +327,136 @@ for (const at of stops) {
}, at);
await page.waitForTimeout(900);
const audit = await dayAudit();
for (const section of await page.$$eval('.adm-day', (els) => els.map((e) => e.getAttribute('data-key')))) seenDays.add(section);
scrolledBad = scrolledBad.concat(audit.bad);
const rail = await railOf();
// The month the rail says the eye is in has to own the day at the fold, 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.onKind !== 'month' || rail.fold.slice(0, 7) !== rail.on.slice(0, 7)) {
railBad.push({ at, fold: rail.fold, on: rail.on, kind: rail.onKind });
} else {
seenMonths.add(rail.on);
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 months1 = monthsOf(rail1);
// The rail carries the shelf's own order — the reader's sort, months down the wall as
// the wall holds them — so what is compared here is the set of months, not the order
// twice over.
step(
'the rail holds the shelf by the year and the month, and by nothing finer',
[...months1].sort().join() === [...MONTHS].sort().join() &&
[...yearsOf(rail1)].sort().join() === [...YEARS].sort().join() &&
rail1.kinds.every((k) => k === 'year' || k === 'month'),
`${yearsOf(rail1).length} years, ${months1.length} months of ${MONTHS.length}`
);
step(
'and the entry it reads follows the eye down the shelf',
railBad.length === 0 && seenMonths.size >= 3,
`${seenMonths.size} of ${MONTHS.length} months read, ${JSON.stringify(railBad.slice(0, 2))}`
);
step(
'the rail is longer than the wall is tall, and scrolls under the reader',
rail1.whole > rail1.box,
`${rail1.whole}px of rail in ${rail1.box}px of box`
scrolledBad.length === 0 && seenDays.size > 1,
`${seenDays.size} days through the window, ${JSON.stringify(scrolledBad.slice(0, 2))}`
);
// And the press: the shelf's own first day of the month is where the wall has to
// stand, its header just under the wall's top edge, the entry pressed is the month
// read afterwards, and the rail brings that entry into its own box.
const press = async (key) => {
await page.click(`[data-key="lib-rail-${key}"]`);
await page.waitForTimeout(700);
return page.evaluate((k) => {
const grid = document.querySelector('.adm-grid');
const rail = document.querySelector('.lib-rail');
const button = document.querySelector(`[data-key="lib-rail-${k}"]`);
const at = button?.getAttribute('data-at') ?? null;
const section = at ? grid.querySelector(`[data-key="lib-day-${at}"]`) : null;
const on = document.querySelector('.lib-rail-item.on');
const y = button ? button.offsetTop - rail.scrollTop : null;
// Back to the head of the roll, where the month's own band is drawn.
await page.evaluate(() => {
const el = document.querySelector('.adm-grid');
el.scrollTop = 0;
el.dispatchEvent(new Event('scroll'));
});
await page.waitForTimeout(1200);
// The two bands, at the heights the wall counts its shelf in. The month's and the
// day's band are both fixed numbers in the style sheet and the shelf's whole
// length — and with it every spacer standing in for the rows not drawn — 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 counts them at', bands.month === 54 && bands.day === 28, 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 monthAudit = () =>
page.evaluate(() => {
const out = { sections: 0, heads: 0, bad: [] };
const sections = [...document.querySelectorAll('.adm-day')];
out.sections = sections.length;
let prev = null;
for (const section of sections) {
const month = section.getAttribute('data-month') ?? '';
const head = section.querySelector('.adm-month-head');
const named = head ? (head.getAttribute('data-key') ?? '').replace('lib-month-', '') : '';
if (head) out.heads++;
// 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) {
if ((month !== prev) !== Boolean(head)) out.bad.push({ month, prev, head: Boolean(head) });
else if (head && named !== month) out.bad.push({ month, named });
}
prev = month;
}
return out;
});
const months1 = await monthAudit();
step('the wall names a month over the first day of it, and only there', months1.heads >= 1 && months1.bad.length === 0, `${months1.heads} month bands over ${months1.sections} days, ${JSON.stringify(months1.bad.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 head = document.querySelector(`[data-key="lib-month-${key}"]`);
const on = document.querySelector('.lib-ruler-tick.on');
return {
drawn: Boolean(head),
offset: head && grid ? Math.round(head.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 ticksAll = [...document.querySelectorAll('.lib-ruler-tick')];
const months = ticksAll.filter((t) => t.getAttribute('data-kind') === 'month');
const needle = document.querySelector('[data-key="lib-ruler-needle"]');
return {
at,
top: section ? Math.round(section.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
cards: section ? section.querySelectorAll('.adm-card').length : 0,
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
onKind: on?.getAttribute('data-kind') ?? null,
seen: y !== null && y >= -2 && y + (button?.offsetHeight ?? 0) <= rail.clientHeight + 2,
on: months.findIndex((t) => t.classList.contains('on')),
count: months.length,
needle: needle ? parseFloat(needle.style.top) || 0 : 0,
tick: months.find((t) => t.classList.contains('on')) ? parseFloat(months.find((t) => t.classList.contains('on')).style.top) || 0 : 0,
};
}, key);
};
const jumped = months1[Math.floor(months1.length / 2)];
const landed = await press(jumped);
step(
'a press on a month stands the wall on that month, and the rail on its entry',
landed.top !== null &&
Math.abs(landed.top - 6) <= 6 &&
landed.on === jumped &&
landed.onKind === 'month' &&
landed.seen,
`header at ${landed.top} of ${landed.at}, entry ${landed.on}, in rail ${landed.seen}`
);
});
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 year is a head and not a month of the shelf: a press on it stands the wall on
// the first month the shelf holds of that year, and the eye then reads that month.
const yearRow = yearsOf(rail1)[1];
const overYear = await press(yearRow);
step(
'a press on a year stands the wall on the first month it holds',
overYear.top !== null && Math.abs(overYear.top - 6) <= 6 && overYear.at?.slice(0, 4) === yearRow && overYear.on === overYear.at?.slice(0, 7),
`year ${yearRow}: header at ${overYear.top} of ${overYear.at}, entry ${overYear.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.
// 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
// stands in for the frames it is not drawing, and a spacer standing in for more
// rows than those frames take leaves the reader at the end of the scroll with a
// screen of nothing under the last card drawn 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();
@@ -533,75 +481,35 @@ const walked = await page.evaluate(async () => {
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.
// What is left under the last card drawn: the spacer that has nothing left
// to stand in for. A few pixels is the grid's own gap.
gap: last ? Math.round(el.scrollHeight - (last.getBoundingClientRect().bottom - box.top + el.scrollTop)) : 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 <= 16, `${walked.gap}px of nothing under the last card`);
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'}`);
// What the wall mints, read at the far end of the shelf: the tiles are the frames of
// the days around the eye, and a shelf walked end to end is not a shelf of tiles held
// end to end. A frame the wall holds no tile for draws nothing at all — a folder off
// the disk is a file only this page can open — so the count of blob-backed images is
// the count of tiles the wall is holding.
const held = await page.evaluate(() => ({
cards: document.querySelectorAll('.adm-grid .adm-card').length,
tiles: document.querySelectorAll('.adm-grid img[src^="blob:"]').length,
}));
step(
'the wall mints the tiles around the eye and not the whole shelf',
held.tiles > 0 && held.tiles < held.cards / 2,
`${held.tiles} tiles held of ${held.cards} cards`
);
// 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);
// 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 resized under its own eye does not take the screen down',
!after.blank && after.cards === FRAMES && looped.length === 0,
'a wall fed its own window does not take the screen down',
!after.blank && after.cards > 0 && 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();
const smallMonth = SMALL_DAY_KEYS[0].slice(0, 7);
step(
'a shelf of one month is still read by the year and the month',
rail3.kinds.join() === 'year,month' && yearsOf(rail3).length === 1 && monthsOf(rail3).join() === smallMonth,
`${rail3.labels.join(' ')} / ${rail3.keys.join(' ')}`
);
const overMonth = await press(smallMonth);
step(
'and a press on it stands the wall on that month, whole',
overMonth.top !== null && Math.abs(overMonth.top - 6) <= 6 && overMonth.cards === SMALL_EACH && overMonth.on === smallMonth,
`header at ${overMonth.top}, ${overMonth.cards} cards, entry ${overMonth.on}`
);
step('the wall is still the wall', after.cards > 0, `${after.cards} cards 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(' // '));