fix(library): measure the wall's shelf in two steps so the scroll reaches its end

This commit is contained in:
2026-10-13 09:15:59 +07:00
parent 7040d7c2e6
commit fcded40c65
4 changed files with 503 additions and 82 deletions
+94 -12
View File
@@ -37,14 +37,36 @@ const FOLDER = 'WallRoll';
// 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 PER_DAY = Number(process.env.PER_DAY ?? 10);
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 dayOfName = (name) => {
const n = Number((name.match(/\d+/) ?? ['1'])[0]);
return DAY0 + Math.floor(Math.max(0, n - 1) / PER_DAY) * DAY_MS;
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;
let failures = 0;
function step(name, ok, detail = '') {
@@ -262,20 +284,80 @@ step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < F
step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls');
const days1 = await dayAudit();
step('the wall is cut into the days its frames were shot', days1.sections > 1, `${days1.sections} headers over ${days1.cards} cards`);
// 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)));
step('and every header carries its day in words', days1.labelled === days1.sections, `${days1.labelled} of ${days1.sections}`);
// 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.
await page.evaluate(() => {
// 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);
}
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 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');
el.scrollTop = Math.round(el.scrollHeight * 0.7);
el.dispatchEvent(new Event('scroll'));
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: 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],
};
});
await page.waitForTimeout(1500);
const days2 = await dayAudit();
step('a scrolled wall still files every card under its own day', days2.sections > 0 && days2.bad.length === 0, JSON.stringify(days2.bad.slice(0, 2)));
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'}`);
// The class of trigger, made to happen: one input the wall reads back off itself,
// moving on every pass.
+146
View File
@@ -0,0 +1,146 @@
// The wall's own model of its rows (src/ui/dateGroups.ts, `bandRows` /
// `rowPhoto` / `photoRow`), checked where it can go wrong without a browser.
//
// The wall draws the rows around the reader and a spacer stands in for the rest,
// so a model of the rows that is out of step with the shelf drawn is a reader
// stopping at a screen of nothing with frames below them never drawn — which is
// what a flat `count / cols` count of rows is the moment the shelf is cut into
// days: every day spends a row on its band, and a day whose frames do not come
// out even over the columns leaves the rest of its last row empty.
//
// What has to hold is that the rows tile the shelf: one window per row, in
// order, meeting at both ends and never leaving a frame between two of them, and
// that a frame stands in the row the browser will draw it in.
//
// node scripts/wall-slot-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const src = readFileSync(new URL('../src/ui/dateGroups.ts', import.meta.url), 'utf8');
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { bandRows, dayAt, rowPhoto, photoRow, rowAt, rowTop, wallLayout } = await import(
`data:text/javascript,${encodeURIComponent(js)}`
);
// The frames per day, cycling — and with them the shelves the check stands on: a
// shelf of equal days, a shelf of single-frame days, one day of everything, and
// the rolls in between, which is what a camera roll is and where a flat count of
// rows stops being right.
const SHELVES = [
[300, [10]],
[200, [1]],
[120, [120]],
[300, [1, 40, 2, 3, 60, 1, 8, 120, 4, 2, 25, 7]],
[1, [1]],
[7, [2, 3]],
[0, []],
];
const COLUMNS = [1, 2, 3, 6, 7, 13];
// The two steps a shelf is stacked in, in the two shapes a measurement of them can
// come out: a band of words a good deal shorter than a row of pictures, which is
// the shelf on screen, and the two equal, which is what the model looked like when
// it was one step and has to keep working.
const STEPS = [
{ band: 44, row: 208, gap: 12 },
{ band: 208, row: 208, gap: 12 },
];
let checked = 0;
for (const [count, pattern] of SHELVES) {
// The first frame of every day, off the pattern — the same list the wall cuts
// its own `dayStarts` into.
const days = [];
for (let i = 0, day = 0; i < count; day++) {
days.push(i);
i += pattern[day % pattern.length];
}
assert.equal(days[0] ?? 0, 0);
for (const cols of COLUMNS) {
const order = bandRows(days, count, cols);
const rows = order[days.length];
// One band per day, and its frames under it in rows of `cols`: the rows the
// shelf is drawn in, which is what the model has to come to.
let drawn = 0;
for (let k = 0; k < days.length; k++) {
drawn += 1 + Math.ceil(((days[k + 1] ?? count) - days[k]) / cols);
}
assert.equal(rows, drawn, `${count} frames in ${days.length} days at ${cols} columns: ${rows} rows modelled, ${drawn} drawn`);
// The rows tile the shelf: row `r` draws from `rowPhoto(r)` and the next row
// takes it up from there, the first row starts at the first frame, and the
// row past the last answers with the end of the shelf.
let from = rowPhoto(days, count, cols, order, 0);
assert.equal(from, 0, `${count}/${cols}: the shelf starts at its first frame`);
for (let r = 1; r <= rows; r++) {
const to = rowPhoto(days, count, cols, order, r);
assert.ok(to >= from, `${count}/${cols}: row ${r} answers ${to}, behind ${from}`);
assert.ok(to <= count, `${count}/${cols}: row ${r} answers ${to} of ${count}`);
if (r === rows) assert.equal(to, count, `${count}/${cols}: the last row reaches the end`);
from = to;
}
// And a frame's own row is the row it is drawn in: that row's window starts
// at or above it, and the row after starts below it.
for (let i = 0; i < count; i++) {
const row = photoRow(days, cols, order, i);
assert.ok(row >= 1 && row < rows, `${count}/${cols}: frame ${i} is drawn in row ${row} of ${rows}`);
assert.ok(rowPhoto(days, count, cols, order, row) <= i, `${count}/${cols}: frame ${i} stands past the start of row ${row}`);
assert.ok(rowPhoto(days, count, cols, order, row + 1) > i, `${count}/${cols}: frame ${i} stands past the end of row ${row}`);
checked++;
}
// The shelf's own height and the two spacers that stand in for the days not
// drawn, in the two steps it is actually stacked in. What has to hold is that
// the shelf comes out the same height whichever window is over it: a height
// that moves with the window is the reader walking down to the end of the
// scroll and stopping a screen short of the frames it was standing in for.
for (const steps of STEPS) {
const at = `${count} frames in ${days.length} days at ${cols} columns, band ${steps.band}/row ${steps.row}`;
const layout = wallLayout(days, count, cols, steps);
const heights = days.map(
(start, k) => steps.band + Math.ceil(((days[k + 1] ?? count) - start) / cols) * steps.row
);
const drawnHeight = heights.reduce((sum, h) => sum + h, 0);
assert.equal(layout.height, days.length ? drawnHeight + (days.length - 1) * steps.gap : 0, `${at}: the shelf is ${layout.height} tall`);
assert.equal(layout.rows, rows, `${at}: ${layout.rows} rows of the two steps, ${rows} drawn`);
if (!days.length) continue;
assert.equal(layout.ends[days.length - 1], layout.height, `${at}: the last day ends at the shelf's own height`);
// A row stands below the row before it, and the top it stands at reads back
// as the row it is — which is what puts a scroll where a frame is.
let last = -1;
for (let r = 0; r < layout.rows; r++) {
const top = rowTop(layout, r);
assert.ok(top > last, `${at}: row ${r} stands at ${top}, behind ${last}`);
last = top;
assert.equal(rowAt(layout, top), r, `${at}: row ${r} at ${top} reads back as ${rowAt(layout, top)}`);
}
assert.equal(rowTop(layout, layout.rows - 1) + steps.row, layout.height, `${at}: the last row reaches the shelf's own height`);
assert.equal(dayAt(days, count - 1), days.length - 1, `${at}: the last frame is filed under the last day`);
// And every window of days lays out the same shelf: the spacer over it, the
// days drawn, the spacer under it and the grid's own gaps add up to one
// height, whichever days are drawn.
for (let a = 0; a < days.length; a++) {
for (let b = a; b < days.length; b++) {
// Laid out the way the DOM lays it: a spacer where the wall draws one
// (a height of nought is no spacer), the days drawn, the grid's gap
// between one item and the next.
const above = a ? Math.max(0, layout.tops[a] - steps.gap) : 0;
const below = Math.max(0, layout.height - layout.ends[b] - steps.gap);
const items = b - a + 1 + (above > 0 ? 1 : 0) + (below > 0 ? 1 : 0);
let sum = 0;
for (let k = a; k <= b; k++) sum += heights[k];
const total = above + sum + below + (items - 1) * steps.gap;
assert.equal(total, layout.height, `${at}: days ${a}..${b} stand ${total} tall, the shelf ${layout.height}`);
}
}
checked++;
}
}
}
console.log(`ok ${checked} frames placed on ${SHELVES.length} shelves`);