9fc7b89e74
A roll read at 150ms a frame finishes before the flush clock can be told apart from a batch-only flush. At 900ms the step that watches the frames land has 14 counts to show for the reading instead of 3, and a build that only hands the rows over when a batch is full fails it.
747 lines
32 KiB
JavaScript
747 lines
32 KiB
JavaScript
// The three things the reader reported, checked in a browser: the counts beside
|
||
// the folder names while a reading is running, what the column holds after the
|
||
// tab comes back — every folder the walk went into, the empty ones included, with
|
||
// an interrupted reading picking itself up — and a long roll's tiles, every frame
|
||
// the wall draws read out of the reading that brought them.
|
||
//
|
||
// The app is behind an account and answers `/api` on its own origin, so the check
|
||
// stands in for the API the way the container's nginx does; without it the page
|
||
// is a guest and there is no folder to pick. The catalogue is read back out of
|
||
// IndexedDB rather than out of the strip, which draws one node's own frames.
|
||
//
|
||
// npm run build && node scripts/tree-scan-check.mjs
|
||
// APP_PORT=4395 SAMPLE_PORT=4396 SAMPLE_DIR=<folder of sample frames> \
|
||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> CHROME=<path to chrome> \
|
||
// node scripts/tree-scan-check.mjs
|
||
import { spawn } from 'node:child_process';
|
||
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 SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..', 'sample');
|
||
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
|
||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
|
||
const JPG = 'P1010256.JPG';
|
||
const RAW = 'P1010256.RW2';
|
||
// What the frames of the long roll are served: a small JPEG, because a hundred
|
||
// copies of an eight-megabyte one is eight hundred megabytes to prove a point
|
||
// about tiles.
|
||
const SMALL = 'Main.jpg';
|
||
const LONG = 60;
|
||
const LONG_NAMES = Array.from({ length: LONG }, (_, i) => `B${String(i).padStart(3, '0')}.JPG`);
|
||
|
||
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');
|
||
process.exit(0);
|
||
}
|
||
|
||
// Every frame the page asks for, by extension: one real JPEG and one real RAW — see
|
||
// the route that hands them over, below.
|
||
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||
cwd: FRONTEND,
|
||
stdio: 'ignore',
|
||
});
|
||
const base = `http://127.0.0.1:${APP_PORT}`;
|
||
let up = false;
|
||
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) {
|
||
app.kill('SIGTERM');
|
||
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const browser = await playwright.chromium.launch({
|
||
executablePath: process.env.CHROME || undefined,
|
||
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
|
||
});
|
||
const context = await browser.newContext();
|
||
// The sample frames are handed over inside the browser rather than off a socket of
|
||
// its own: a page is given six sockets to a host, and the walk's sixty reads fill
|
||
// all six, so the shelf's own reads would queue behind the reading they are meant
|
||
// to keep up with — a queue no real disk puts in the way.
|
||
await context.route(`http://127.0.0.1:${SAMPLE_PORT}/**`, async (route) => {
|
||
const name = path.basename(decodeURIComponent(new URL(route.request().url()).pathname));
|
||
const file = /^B\d+\.JPG$/.test(name) ? SMALL : /\.jpe?g$/i.test(name) ? JPG : /\.rw2$/i.test(name) ? RAW : null;
|
||
if (!file) return route.fulfill({ status: 404, body: '' });
|
||
return route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/octet-stream',
|
||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||
body: await readFile(path.join(SAMPLES, file)),
|
||
});
|
||
});
|
||
|
||
// The catalogue is behind an account, and the header reads the account's own
|
||
// folder: the API answers for both, the way the container beside the page does.
|
||
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' } });
|
||
if (url.includes('/photos')) return json({ photos: [] });
|
||
if (url.includes('/recipes')) return json({ recipes: [] });
|
||
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'no' }) });
|
||
});
|
||
|
||
await context.addInitScript(
|
||
({ origin, folder, rolls, small }) => {
|
||
const bytes = new Map();
|
||
// One fetch per real file whatever the handle is called: the frames of the
|
||
// long roll are one small JPEG served under sixty names.
|
||
const load = async (name) => {
|
||
const file = /^B\d+\.JPG$/.test(name) ? small : name;
|
||
if (!bytes.has(file)) bytes.set(file, fetch(`${origin}/${file}`).then((r) => r.arrayBuffer()));
|
||
return bytes.get(file);
|
||
};
|
||
const fileHandle = (name) => {
|
||
const handle = { kind: 'file', name, __fake: true };
|
||
Object.defineProperty(handle, 'getFile', {
|
||
value: async () => {
|
||
const gets = JSON.parse(sessionStorage.getItem('check.gets') ?? '{}');
|
||
gets[name] = (gets[name] ?? 0) + 1;
|
||
sessionStorage.setItem('check.gets', JSON.stringify(gets));
|
||
while (localStorage.getItem('check.hold') === '1') await new Promise((r) => setTimeout(r, 25));
|
||
// A read of a frame is nearly all waiting on a real disk, and a reading
|
||
// that is over in a blink is one the wall's own work cannot be watched
|
||
// against. `check.slow` is that wait, in milliseconds.
|
||
const slow = Number(localStorage.getItem('check.slow') ?? 0);
|
||
if (slow) await new Promise((r) => setTimeout(r, slow));
|
||
return new File([await load(name)], name, { type: 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1) });
|
||
},
|
||
});
|
||
return handle;
|
||
};
|
||
const dirHandle = (label, node) => {
|
||
const handle = { kind: 'directory', name: label, __fake: true };
|
||
Object.defineProperties(handle, {
|
||
values: {
|
||
value: () => {
|
||
const lists = JSON.parse(sessionStorage.getItem('check.lists') ?? '{}');
|
||
lists[label] = (lists[label] ?? 0) + 1;
|
||
sessionStorage.setItem('check.lists', JSON.stringify(lists));
|
||
if (node.fail && localStorage.getItem('check.broken') !== 'open') throw new Error(`${label} will not open`);
|
||
return [
|
||
...(node.files ?? []).map(fileHandle),
|
||
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
||
][Symbol.iterator]();
|
||
},
|
||
},
|
||
queryPermission: { value: async () => 'granted' },
|
||
requestPermission: { value: async () => 'granted' },
|
||
getDirectoryHandle: {
|
||
value: async (name) => {
|
||
const kid = node.dirs?.[name];
|
||
if (!kid) throw new Error(`no folder named ${name}`);
|
||
return dirHandle(name, kid);
|
||
},
|
||
},
|
||
getFileHandle: {
|
||
value: async (name) => {
|
||
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
|
||
return fileHandle(name);
|
||
},
|
||
},
|
||
});
|
||
return handle;
|
||
};
|
||
const pick = (name) => dirHandle(name, { files: rolls[name].files, dirs: rolls[name].dirs });
|
||
// The roll in play, not the one this script was handed: a handle that comes
|
||
// back out of the catalogue belongs to the folder being read, and the reading
|
||
// a reload takes up is of that one.
|
||
const root = () => pick(localStorage.getItem('check.roll') ?? folder);
|
||
window.showDirectoryPicker = async () => root();
|
||
// What the disk holds, for the check to change behind the app's back: a frame
|
||
// taken off the disk is a folder the walk meets one frame fewer in.
|
||
window.__rolls = rolls;
|
||
const revive = (row) => {
|
||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
|
||
}
|
||
return row;
|
||
};
|
||
for (const method of ['get', 'getAll']) {
|
||
const original = IDBObjectStore.prototype[method];
|
||
IDBObjectStore.prototype[method] = function (...args) {
|
||
const request = original.apply(this, args);
|
||
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
|
||
Object.defineProperty(request, 'result', {
|
||
configurable: true,
|
||
get() {
|
||
const value = descriptor.get.call(this);
|
||
return Array.isArray(value) ? value.map(revive) : revive(value);
|
||
},
|
||
});
|
||
return request;
|
||
};
|
||
}
|
||
},
|
||
{
|
||
origin: `http://127.0.0.1:${SAMPLE_PORT}`,
|
||
folder: 'CheckRoll',
|
||
small: SMALL,
|
||
rolls: {
|
||
// One frame at the top, one a folder down, one two folders down, one in a
|
||
// second branch — and a folder holding no frame at all.
|
||
CheckRoll: {
|
||
files: [JPG],
|
||
dirs: {
|
||
2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } },
|
||
2025: { files: [JPG], dirs: {} },
|
||
Empty: { files: ['notes.txt'], dirs: {} },
|
||
},
|
||
},
|
||
// A roll with a folder that will not open: a permission taken back, a
|
||
// volume pulled out — a folder the walk meets and cannot list.
|
||
BrokenRoll: {
|
||
files: [JPG],
|
||
dirs: { Broken: { fail: true, files: [JPG], dirs: {} } },
|
||
},
|
||
// A roll long enough that its reading outlasts the wall's own work: sixty
|
||
// frames read at a walking pace, so the wall has time to tile them while
|
||
// the reading is still going.
|
||
BigRoll: {
|
||
files: LONG_NAMES,
|
||
dirs: {},
|
||
},
|
||
// The same roll read for the first time, on a tab that goes away.
|
||
CutRoll: {
|
||
files: [JPG],
|
||
dirs: { Broken: { fail: true, files: [JPG], dirs: {} } },
|
||
},
|
||
// A roll picked at a drive root, which Chromium hands over named `\` — the
|
||
// one name a folder under it is spelled from and that is itself no folder
|
||
// name at all. See the check at the foot of this file.
|
||
'\\': {
|
||
files: [JPG],
|
||
dirs: { Roll: { files: [RAW], dirs: {} } },
|
||
},
|
||
},
|
||
}
|
||
);
|
||
|
||
const page = await context.newPage();
|
||
const rowStates = () =>
|
||
page.$$eval('[data-key^="lib-node-"]', (els) =>
|
||
els.map((e) => `${e.dataset.key.replace('lib-node-', '')}:${(e.querySelector('.adm-album-count')?.textContent ?? '').trim()}`)
|
||
);
|
||
const progress = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
|
||
const counts = async () => {
|
||
const out = {};
|
||
for (const row of await rowStates()) {
|
||
const [key, n] = row.split(':');
|
||
out[key] = n;
|
||
}
|
||
return out;
|
||
};
|
||
// Row order is the tree's path order, not the order of this file's expectations.
|
||
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
|
||
// What the reading has stored, asked of the catalogue rather than of the strip:
|
||
// the strip draws the open node's own frames, and three of these four are one
|
||
// folder down.
|
||
const stored = () =>
|
||
page
|
||
.evaluate(async () => {
|
||
const db = await new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
return new Promise((resolve, reject) => {
|
||
const req = db.transaction('photos', 'readonly').objectStore('photos').count();
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
})
|
||
.catch(() => -1);
|
||
// What the walk found, which is the whole of what the column is asked to say.
|
||
const WANT = {
|
||
CheckRoll: '4',
|
||
'CheckRoll/2025': '1',
|
||
'CheckRoll/2026': '2',
|
||
'CheckRoll/2026/04': '1',
|
||
'CheckRoll/Empty': '0',
|
||
};
|
||
|
||
// --- a reading, watched while it runs ---------------------------------------
|
||
await page.goto(`${base}/library`);
|
||
// Held on the first frame it reads, so the walk is through its first pass — the
|
||
// names and their numbers are the reading's, and the reading is not done.
|
||
await page.evaluate(() => localStorage.setItem('check.hold', '1'));
|
||
await page.click('[data-key="lib-add"]');
|
||
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 });
|
||
// Every row of the pass is a row now, and a count beside each name is what the
|
||
// walk met — the reading itself has read nothing.
|
||
await page
|
||
.waitForFunction(() => {
|
||
const rows = [...document.querySelectorAll('[data-key^="lib-node-"]')];
|
||
return rows.length === 4 && /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '');
|
||
}, null, { timeout: 60_000, polling: 50 })
|
||
.catch(() => {});
|
||
const midFloat = await counts();
|
||
const midLine = (await progress()).replace(/\s+/g, ' ').trim();
|
||
const midStored = await stored();
|
||
// The whole roll is one pass of four folders, so the numbers are the walk's the
|
||
// moment the reading has listed the tree — before a single frame is read.
|
||
step(
|
||
'the counts beside the folder names are up while the reading runs',
|
||
same(midFloat, WANT),
|
||
`${JSON.stringify(midFloat)} at "${midLine}"`
|
||
);
|
||
step(
|
||
'and they are the walk’s own, not the catalogue’s: nothing has been read yet',
|
||
midStored === 0,
|
||
`${midStored} rows stored`
|
||
);
|
||
step('and the reading is in flight while the column already says so', Boolean(await page.$('[data-key="lib-progress"]')), `"${midLine}"`);
|
||
|
||
// --- the reading runs out ----------------------------------------------------
|
||
await page.evaluate(() => localStorage.removeItem('check.hold'));
|
||
// The progress span is in the page whether or not a reading is, and its text is
|
||
// empty until the first pass has said what the roll is: silence is the reading
|
||
// being through.
|
||
await page
|
||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||
timeout: 120_000,
|
||
polling: 50,
|
||
})
|
||
.catch(() => {});
|
||
const done = await counts();
|
||
step(
|
||
'a reading that ran out leaves the roll counted, empty folders and all',
|
||
same(done, WANT),
|
||
`${JSON.stringify(done)}, ${await stored()} rows stored`
|
||
);
|
||
|
||
// --- the tab comes back ------------------------------------------------------
|
||
await page.reload();
|
||
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 60_000 });
|
||
await page.waitForTimeout(1000);
|
||
const back = await counts();
|
||
step(
|
||
'and the next visit draws the same column with nothing right-clicked',
|
||
same(back, done),
|
||
JSON.stringify(back)
|
||
);
|
||
step(
|
||
'a folder the walk went into and met no frame in keeps its row',
|
||
typeof back['CheckRoll/Empty'] === 'string',
|
||
`Empty: ${back['CheckRoll/Empty']}`
|
||
);
|
||
// Nothing is left to read, so this visit offers nothing and starts nothing: the
|
||
// catalogue comes back off the disk and the screen waits to be asked.
|
||
step(
|
||
'and a visit with nothing left to read reads nothing at all',
|
||
!(await page.$('[data-key="lib-resume"]')) && (await progress()).trim() === '',
|
||
`resume ${Boolean(await page.$('[data-key="lib-resume"]'))}, line "${(await progress()).trim()}"`
|
||
);
|
||
|
||
// --- a reading cut off -------------------------------------------------------
|
||
// The catalogue is emptied first, so every frame is one this reading has to read
|
||
// itself: a reading that does not go on by itself leaves the strip at nothing.
|
||
await page.evaluate(async () => {
|
||
sessionStorage.clear();
|
||
const db = await new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
const tx = db.transaction('photos', 'readwrite');
|
||
tx.objectStore('photos').clear();
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
localStorage.setItem('check.hold', '1');
|
||
});
|
||
const counting = () =>
|
||
page
|
||
.waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, {
|
||
timeout: 60_000,
|
||
polling: 50,
|
||
})
|
||
.then(() => progress())
|
||
.then((line) => line.replace(/\s+/g, ' ').trim())
|
||
.catch(() => '');
|
||
// A reading of the whole roll, held on the first frame it reads: what a tab that
|
||
// is closed mid-reading leaves behind is exactly this.
|
||
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
|
||
await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 });
|
||
await page.click('[data-key="lib-rescan-CheckRoll"]');
|
||
const cut = await counting();
|
||
// The walk writes its own position out at most once a second, and the reload is
|
||
// what has to be cut off mid-reading rather than before it started.
|
||
await page.waitForTimeout(2000);
|
||
// Nothing here asks for another reading, and neither does the visit that comes
|
||
// back: the roll the reload cut off is put on offer beside the progress line, and
|
||
// the press of that offer is the only thing that starts the second reading. The
|
||
// first frame is still held, so a visit that went on by itself would hang on it
|
||
// rather than pass.
|
||
await page.reload();
|
||
await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 });
|
||
const offered = (await progress()).trim();
|
||
step(
|
||
'a reading a reload cut off is offered by the next visit, not run by it',
|
||
/\d+\/\d+/.test(cut) && offered === '',
|
||
`"${cut}" then idle "${offered}"`
|
||
);
|
||
await page.click('[data-key="lib-resume"]');
|
||
const cutAgain = await counting();
|
||
step('and the offer is that reading taken up where it stopped', /\d+\/\d+/.test(cutAgain), `"${cutAgain}"`);
|
||
|
||
await page.evaluate(() => localStorage.removeItem('check.hold'));
|
||
// The progress span is in the page whether or not a reading is, and its text is
|
||
// empty until the first pass has said what the roll is: silence is the reading
|
||
// being through.
|
||
await page
|
||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||
timeout: 120_000,
|
||
polling: 50,
|
||
})
|
||
.catch(() => {});
|
||
const resumed = await stored();
|
||
const lists = await page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
|
||
step(
|
||
'and the visit after it finishes the roll on its own, without listing a folder twice',
|
||
resumed === 4 && lists.CheckRoll === 1 && lists['2026'] === 1 && lists.Empty === 1 && lists['2025'] === 1,
|
||
`${resumed} rows stored from ${JSON.stringify(lists)}`
|
||
);
|
||
|
||
const keys = (store) =>
|
||
page
|
||
.evaluate(
|
||
(store) =>
|
||
new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => {
|
||
const db = req.result;
|
||
const get = db.transaction(store, 'readonly').objectStore(store).getAllKeys();
|
||
get.onsuccess = () => resolve(get.result);
|
||
get.onerror = () => reject(get.error);
|
||
};
|
||
req.onerror = () => reject(req.error);
|
||
}),
|
||
store
|
||
)
|
||
.catch(() => []);
|
||
const quiet = () =>
|
||
page
|
||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||
timeout: 120_000,
|
||
polling: 50,
|
||
})
|
||
.catch(() => {});
|
||
// A reading, watched out: the line is up once it has told the column what the
|
||
// roll is, and silence is the reading being through. Waiting for silence alone
|
||
// would read the line before the reading has written it.
|
||
const readOut = async () => {
|
||
await page
|
||
.waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, {
|
||
timeout: 20_000,
|
||
polling: 50,
|
||
})
|
||
.catch(() => {});
|
||
await quiet();
|
||
};
|
||
// A row for a frame the disk has not got, with the tile and the marks that go
|
||
// with it — the state a catalogue is left in when a frame is taken off the disk.
|
||
const seed = (ids) =>
|
||
page.evaluate(
|
||
(ids) =>
|
||
new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => {
|
||
const db = req.result;
|
||
const tx = db.transaction(['photos', 'thumbs', 'edits'], 'readwrite');
|
||
for (const id of ids) {
|
||
const parts = id.split('/');
|
||
tx.objectStore('photos').put({
|
||
id,
|
||
folder: parts[0],
|
||
dir: parts.slice(1, -1).join('/'),
|
||
name: parts[parts.length - 1],
|
||
handle: { kind: 'file', name: parts[parts.length - 1], __fake: true },
|
||
taken: 1,
|
||
size: 1,
|
||
mtime: 1,
|
||
addedAt: 1,
|
||
});
|
||
tx.objectStore('thumbs').put({ id, thumb: new Blob(['x']) });
|
||
tx.objectStore('edits').put({ photoId: id, recipe: {}, updatedAt: 1 });
|
||
}
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
};
|
||
req.onerror = () => reject(req.error);
|
||
}),
|
||
ids
|
||
);
|
||
|
||
// --- the catalogue brought to the disk --------------------------------------
|
||
// The reader's report: a folder's frames and the number kept for it disagree, and
|
||
// a rescan of the parent does not put them right. Both halves are here — a frame
|
||
// taken off the disk while its row stayed in the catalogue, and a row for a frame
|
||
// that was never on the disk at all — and one rescan of the roll has to settle
|
||
// both, the numbers under the subfolders included.
|
||
await seed(['CheckRoll/2025/Gone.JPG', 'BrokenRoll/Broken/x.JPG']);
|
||
await page.evaluate(() => {
|
||
window.__rolls.CheckRoll.dirs['2025'].files.length = 0;
|
||
});
|
||
step(
|
||
'the catalogue is holding two rows the disk has nothing for',
|
||
(await keys('photos')).includes('CheckRoll/2025/Gone.JPG') &&
|
||
(await keys('photos')).includes('CheckRoll/2025/P1010256.JPG'),
|
||
JSON.stringify(await keys('photos'))
|
||
);
|
||
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
|
||
await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 });
|
||
await page.click('[data-key="lib-rescan-CheckRoll"]');
|
||
await readOut();
|
||
const repaired = await counts();
|
||
const rows = (await keys('photos')).filter((k) => k.startsWith('CheckRoll/'));
|
||
step(
|
||
'a rescan of the parent brings every number under it to the disk',
|
||
same(repaired, {
|
||
...WANT,
|
||
CheckRoll: '3',
|
||
'CheckRoll/2025': '0',
|
||
}),
|
||
JSON.stringify(repaired)
|
||
);
|
||
step(
|
||
'and the frames the reading did not meet are gone from the catalogue',
|
||
rows.length === 3 && !rows.includes('CheckRoll/2025/P1010256.JPG') && !rows.includes('CheckRoll/2025/Gone.JPG'),
|
||
JSON.stringify(rows)
|
||
);
|
||
step(
|
||
'with their tiles and their marks',
|
||
!(await keys('thumbs')).includes('CheckRoll/2025/Gone.JPG') &&
|
||
!(await keys('edits')).includes('CheckRoll/2025/Gone.JPG'),
|
||
JSON.stringify(await keys('edits'))
|
||
);
|
||
|
||
// --- a folder the walk could not open ---------------------------------------
|
||
// The other side of the same rule: the walk met nothing under `Broken`, and a
|
||
// reading that read silence for absence would empty a folder it never listed.
|
||
// The number beside its name is the catalogue's, which is the last reading that
|
||
// could open it: a reading that could not open a folder writes no nought there —
|
||
// a nought takes the place of the frames the catalogue holds and reads as a
|
||
// folder gone empty until the reader rescans the roll.
|
||
await page.evaluate(() => localStorage.setItem('check.roll', 'BrokenRoll'));
|
||
await page.click('[data-key="lib-add"]');
|
||
await page.waitForSelector('[data-key="lib-node-BrokenRoll"]', { timeout: 60_000 });
|
||
await readOut();
|
||
const broken = await counts();
|
||
step('a folder the walk could not open keeps its row', broken['BrokenRoll/Broken'] === '1', JSON.stringify(broken));
|
||
step(
|
||
'and nothing under it is dropped: silence is not absence',
|
||
(await keys('photos')).includes('BrokenRoll/Broken/x.JPG'),
|
||
JSON.stringify(await keys('photos'))
|
||
);
|
||
step(
|
||
'while the frame the walk did meet is stored',
|
||
(await keys('photos')).includes('BrokenRoll/P1010256.JPG'),
|
||
JSON.stringify(await keys('photos'))
|
||
);
|
||
|
||
// --- a folder the reading could not open, and a tab that goes away -----------
|
||
// Why the number beside a folder stays at nothing through update after update,
|
||
// which is the state the reader photographed. A roll is read for the first time
|
||
// and the tab goes while the reading is in it: the position it leaves behind must
|
||
// not say it walked a folder it could not open, because an update takes that
|
||
// position up — a folder filed as read is a folder no update opens again, and its
|
||
// number stays at nothing however many times the reader asks. The folder opens on
|
||
// the visit that takes the reading up, and the reading is there to open it.
|
||
const opens = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
|
||
const opened = (m, name) => m[name] ?? 0;
|
||
await page.evaluate(async () => {
|
||
sessionStorage.removeItem('check.lists');
|
||
localStorage.setItem('check.roll', 'CutRoll');
|
||
localStorage.setItem('check.hold', '1');
|
||
// Nothing in hand: the frame is one this reading has to read itself, which is
|
||
// what the hold is holding — and a reading held on the frame it is reading is
|
||
// the reading a closed tab leaves behind.
|
||
const db = await new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
const tx = db.transaction('photos', 'readwrite');
|
||
tx.objectStore('photos').clear();
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
});
|
||
await page.click('[data-key="lib-add"]');
|
||
await page.waitForSelector('[data-key="lib-node-CutRoll"]', { timeout: 60_000 });
|
||
await counting();
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
const askedBroken = opened(await opens(), 'Broken');
|
||
const held = await page.evaluate(async () => {
|
||
const root = await navigator.storage.getDirectory();
|
||
try {
|
||
const dir = await root.getDirectoryHandle('walk', { create: false });
|
||
const fh = await dir.getFileHandle(`${encodeURIComponent('CutRoll')}.json`, { create: false });
|
||
return JSON.parse(await (await fh.getFile()).text());
|
||
} catch {
|
||
return null;
|
||
}
|
||
});
|
||
step(
|
||
'the position a closed tab leaves files no folder it could not open as walked',
|
||
!!held && !held.walked.some((r) => r.startsWith('Broken')) && held.pending.includes('Broken'),
|
||
JSON.stringify(held && { walked: held.walked, pending: held.pending })
|
||
);
|
||
// The tab goes, and the roll it cut off is offered on the visit that comes back:
|
||
// the offer is pressed, and the reading it takes up is the reading that opens the
|
||
// folder the last one could not.
|
||
await page.evaluate(() => localStorage.setItem('check.broken', 'open'));
|
||
await page.reload();
|
||
await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 });
|
||
await page.click('[data-key="lib-resume"]');
|
||
const again = await counting();
|
||
await page.evaluate(() => localStorage.removeItem('check.hold'));
|
||
await readOut();
|
||
const restarted = await counts();
|
||
step(
|
||
'and the visit that takes the reading up is the visit that opens it',
|
||
opened(await opens(), 'Broken') > askedBroken,
|
||
`Broken asked ${askedBroken} times, then ${opened(await opens(), 'Broken')}`
|
||
);
|
||
step(
|
||
'so the folder is counted on the disk, not left at nothing',
|
||
restarted['CutRoll'] === '2' && restarted['CutRoll/Broken'] === '1',
|
||
JSON.stringify(restarted)
|
||
);
|
||
|
||
// --- a roll picked at a drive root ------------------------------------------
|
||
// The reader picks a whole drive and every number beside a folder name stands at
|
||
// nought. The picker hands that folder over named `\`, and a row's number is
|
||
// looked up by the path that name spells: swapped for a bare backslash the roll
|
||
// is `/` and its folders `//Roll`, while the walk files its frames — and writes
|
||
// its counts — under the empty root a `normPath` fold leaves behind. Two
|
||
// spellings of one folder, and a column of noughts over a roll full of frames.
|
||
await page.evaluate(async () => {
|
||
sessionStorage.removeItem('check.lists');
|
||
localStorage.removeItem('check.hold');
|
||
localStorage.setItem('check.roll', '\\');
|
||
const db = await new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
const tx = db.transaction('photos', 'readwrite');
|
||
tx.objectStore('photos').clear();
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
});
|
||
await page.click('[data-key="lib-add"]');
|
||
await page.waitForSelector('[data-key="lib-node-/Roll"]', { timeout: 60_000 }).catch(() => {});
|
||
// The reading runs out on its own, and silence is the reading being through.
|
||
await page
|
||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||
timeout: 120_000,
|
||
polling: 50,
|
||
})
|
||
.catch(() => {});
|
||
const drive = await counts();
|
||
step(
|
||
'a roll picked at a drive root counts its frames beside its own name',
|
||
drive[''] === '2' && drive['/Roll'] === '1',
|
||
JSON.stringify(drive)
|
||
);
|
||
|
||
// --- the shelf, out of a reading ---------------------------------------------
|
||
// The reading hands the screen the frames it found a batch at a time, and a batch
|
||
// used to be handed on only when it was full: the reader's folder of 26 RAWs is
|
||
// under one batch, so the screen stood empty for the whole of the reading and then
|
||
// filled at once, and the tiles had nothing to be drawn from until the reading was
|
||
// over. Frames go down on the clock as well now, so the count climbs and the first
|
||
// tiles appear while the roll is still being read. Sixty frames read at a walking
|
||
// pace is a long roll read the way the reader read theirs.
|
||
await page.evaluate(async () => {
|
||
sessionStorage.removeItem('check.lists');
|
||
localStorage.removeItem('check.hold');
|
||
localStorage.setItem('check.slow', '900');
|
||
localStorage.setItem('check.roll', 'BigRoll');
|
||
const db = await new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
const tx = db.transaction(['photos', 'thumbs'], 'readwrite');
|
||
tx.objectStore('photos').clear();
|
||
tx.objectStore('thumbs').clear();
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
});
|
||
await page.click('[data-key="lib-add"]');
|
||
await page.waitForSelector('[data-key="lib-node-BigRoll"]', { timeout: 60_000 });
|
||
await page
|
||
.waitForFunction(() => (document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim().length > 0, null, {
|
||
timeout: 60_000,
|
||
polling: 25,
|
||
})
|
||
.catch(() => {});
|
||
// The reading is in flight and the frames are being read: what is asked of it here
|
||
// is whether the screen is told of them as they land. A roll under one `BATCH`
|
||
// reached the screen in a single lump at the end of the reading, however long the
|
||
// reading was — the reader's folder of 26 RAWs was one batch, and so one empty
|
||
// screen for the whole of it. Every count between none and all is a reading that
|
||
// said something on the way past.
|
||
const landed = [];
|
||
for (let i = 0; i < 120; i++) {
|
||
const reading = await page
|
||
.$eval('[data-key="lib-progress"]', (e) => (e.textContent ?? '').trim())
|
||
.catch(() => '');
|
||
const listed = Number(
|
||
(await page.$eval('[data-key="lib-listed"]', (e) => e.textContent ?? '').catch(() => '0')).replace(/\D+/g, '')
|
||
);
|
||
if (reading && listed > 0) landed.push(listed);
|
||
if (!reading) break;
|
||
await new Promise((r) => setTimeout(r, 150));
|
||
}
|
||
const seen = [...new Set(landed)];
|
||
step(
|
||
'the frames the reading lands are on screen as it lands them',
|
||
seen.length >= 2,
|
||
`${seen.length} counts while the reading ran: ${seen.join(', ') || 'none'}`
|
||
);
|
||
await page
|
||
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
|
||
timeout: 120_000,
|
||
polling: 25,
|
||
})
|
||
.catch(() => {});
|
||
// The frames the wall draws are read one behind another and the tiles land in
|
||
// starts and stops, so the count is taken once it has stood still for a while.
|
||
let atEnd = 0;
|
||
for (let i = 0, same = 0; i < 160 && same < 10; i++) {
|
||
await new Promise((r) => setTimeout(r, 250));
|
||
const n = (await keys('thumbs')).length;
|
||
same = n === atEnd ? same + 1 : 0;
|
||
atEnd = n;
|
||
}
|
||
step('the wall tiles the frames a reading brought it', atEnd >= 10, `${atEnd} tiles of ${LONG} drawn`);
|
||
await page.evaluate(() => localStorage.removeItem('check.slow'));
|
||
|
||
await browser.close();
|
||
app.kill('SIGTERM');
|
||
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
|
||
process.exit(failures ? 1 : 0);
|