feat(library): lay the ALL THUMBS wall out in justified rows per day, and stop clipping the frames at the pane's edges

This commit is contained in:
2026-10-13 11:10:57 +07:00
parent 326bcb0c5e
commit 1ca8ac6031
4 changed files with 698 additions and 678 deletions
+196 -127
View File
@@ -243,12 +243,73 @@ await context.addInitScript(
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 {
cards: el ? el.querySelectorAll('.adm-card').length : 0,
wall: el ? el.scrollHeight > el.clientHeight : false,
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'),
};
@@ -267,88 +328,103 @@ await page
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;
});
await page.click('[data-key="lib-view-grid"]');
await page.waitForSelector('.adm-grid', { timeout: 15_000 });
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 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');
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 days1 = await dayAudit();
// 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)));
// 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 seenDays = new Set();
let scrolledBad = [];
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();
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 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(
'a scrolled wall still files every card under its own day',
scrolledBad.length === 0 && seenDays.size > 1,
`${seenDays.size} days through the window, ${JSON.stringify(scrolledBad.slice(0, 2))}`
'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 page.evaluate(() => {
const el = document.querySelector('.adm-grid');
el.scrollTop = 0;
el.dispatchEvent(new Event('scroll'));
});
await scrollTo(0);
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.
// 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);
@@ -356,34 +432,27 @@ const bands = await page.evaluate(() => {
};
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));
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 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))}`);
// 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
@@ -410,11 +479,11 @@ 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 band = 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,
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') : '',
};
@@ -426,18 +495,18 @@ step(
`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.
// 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 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 ? 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,
needle: needle ? Number.parseFloat(needle.style.top) || 0 : 0,
tick: on ? Number.parseFloat(on.style.top) || 0 : 0,
};
});
const beforeNotch = await notch();
@@ -452,16 +521,16 @@ step('and the needle stands in the month the eye is in', Math.abs(afterNotch.nee
// 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.
// 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 card of el.querySelectorAll('.adm-card')) seen.add(card.getAttribute('data-key'));
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;
@@ -474,21 +543,21 @@ const walked = await page.evaluate(async () => {
await breathe();
}
collect();
const cards = [...el.querySelectorAll('.adm-card')];
const last = cards[cards.length - 1];
const box = el.getBoundingClientRect();
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 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,
// 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 <= 16, `${walked.gap}px of nothing under the last card`);
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'}`);
@@ -506,10 +575,10 @@ 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.cards > 0 && looped.length === 0,
`blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}`
!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.cards > 0, `${after.cards} cards after the feed`);
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(' // '));