134 lines
6.2 KiB
JavaScript
134 lines
6.2 KiB
JavaScript
// The walk that reads a picked folder (src/engine/rollWalk.ts) is the part of a
|
|
// scan that can run for minutes, and its order is what the reader sees: the
|
|
// picked folder with its own frames, then every folder one layer down with
|
|
// theirs, then the layer below that — and the folder they just clicked in front
|
|
// of everything. None of that can be watched in a browser, so it is walked here
|
|
// over a stand-in tree.
|
|
//
|
|
// node scripts/roll-walk-check.mjs
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import ts from 'typescript';
|
|
|
|
const src = readFileSync(new URL('../src/engine/rollWalk.ts', import.meta.url), 'utf8');
|
|
const js = ts.transpileModule(src, {
|
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
}).outputText;
|
|
const { DIRS_PER_PASS, jumpQueue, walkPass } = await import(`data:text/javascript,${encodeURIComponent(js)}`);
|
|
|
|
// A directory handle is only ever read for `values()` and `getDirectoryHandle()`,
|
|
// so a stand-in tree needs nothing else: `{ name: { … } }` for folders and
|
|
// `name: null` for a file. `@eaDir` and `.thumbnails` are the folders a real roll
|
|
// drags along.
|
|
const dir = (name, children) => ({
|
|
kind: 'directory',
|
|
name,
|
|
values: async function* () {
|
|
for (const [child, kids] of Object.entries(children)) {
|
|
yield kids === null ? { kind: 'file', name: child } : dir(child, kids);
|
|
}
|
|
},
|
|
getDirectoryHandle: async (part) => {
|
|
if (!(part in children) || children[part] === null) throw new DOMException('not a folder', 'NotFoundError');
|
|
return dir(part, children[part]);
|
|
},
|
|
});
|
|
const keep = (name) => /\.(jpe?g|rw2)$/i.test(name);
|
|
// A walk is its root, its queue, and the folders it has read.
|
|
const at = (root, pending) => ({ root, pending, walked: new Set() });
|
|
|
|
const roll = dir('CheckRoll', {
|
|
'.thumbnails': { hidden: null },
|
|
'@eaDir': { junk: null },
|
|
'P1.JPG': null,
|
|
'Empty': { 'notes.txt': null },
|
|
'2026': { 'P2.JPG': null, '04': { 'P3.RW2': null, 'notes.txt': null } },
|
|
'2025': { 'P4.JPG': null },
|
|
});
|
|
|
|
// Walks to the end, one click per pass, which is what the browser does between
|
|
// passes: whatever the reader clicked last is what `jump` names. `seen` is the
|
|
// folders each pass named, which is what the column draws.
|
|
const walk = async (jumps = []) => {
|
|
const tree = at(roll, [{ dir: roll, rel: '' }]);
|
|
const out = [];
|
|
let clicked = 0;
|
|
let passes = 0;
|
|
let walked = false;
|
|
let seen = null;
|
|
while (!walked) {
|
|
const names = [];
|
|
walked = await walkPass(tree, out, names, keep, () => jumps[clicked++] ?? null);
|
|
passes++;
|
|
seen ??= names;
|
|
}
|
|
return { out, names: seen, passes };
|
|
};
|
|
|
|
// Nothing clicked: the picked folder's own frames, then a layer down, then the
|
|
// deepest — a folder is never read before the folder that holds it. Every folder
|
|
// is named in the pass that finds it, which is how a row exists before its own
|
|
// frames do.
|
|
const { out, names, passes } = await walk();
|
|
const rels = out.map((f) => f.rel);
|
|
assert.deepEqual(rels, ['P1.JPG', '2025/P4.JPG', '2026/P2.JPG', '2026/04/P3.RW2'], 'layer by layer from the parent down');
|
|
assert.ok(
|
|
rels.every((rel, i) => i === 0 || rel.split('/').length >= rels[i - 1].split('/').length),
|
|
'no deeper frame is read before a shallower one'
|
|
);
|
|
assert.ok(!rels.some((rel) => /\.thumbnails|@eaDir|notes\.txt/.test(rel)), 'hidden and unsupported names are skipped');
|
|
assert.deepEqual(
|
|
names,
|
|
['2025/', '2026/', 'Empty/', '2026/04/'],
|
|
'the first pass names every folder it walked into, frames or no frames'
|
|
);
|
|
assert.ok(names.includes('Empty/'), 'a folder holding no frame at all is still a row');
|
|
assert.equal(passes, 1, 'a roll this size is one pass, and the names are in it');
|
|
|
|
// A click on a folder one layer down, while that layer is still to be read, puts
|
|
// it first: the frames the reader is looking at are read before the branch beside
|
|
// them.
|
|
const clicked = await walk([null, '2026/']);
|
|
assert.deepEqual(
|
|
clicked.out.map((f) => f.rel),
|
|
['P1.JPG', '2026/P2.JPG', '2025/P4.JPG', '2026/04/P3.RW2'],
|
|
'the clicked folder jumps the queue'
|
|
);
|
|
|
|
// A folder the walk has not reached yet — a row on screen from the parent pass —
|
|
// is opened from the picked folder and read next: `getDirectoryHandle` walks the
|
|
// path, and a path that will not open is left where it was.
|
|
const deep = at(roll, [{ dir: dir('Other', { 'X.JPG': null }), rel: 'other/' }]);
|
|
const deepOut = [];
|
|
await walkPass(deep, deepOut, [], keep, () => '2026/04/');
|
|
assert.deepEqual(
|
|
deepOut.map((f) => f.rel),
|
|
['2026/04/P3.RW2', 'other/X.JPG'],
|
|
'a folder the queue never held is opened and read first'
|
|
);
|
|
const missing = at(roll, [{ dir: roll, rel: '' }]);
|
|
await jumpQueue(missing, '2026/nope/');
|
|
assert.equal(missing.pending.length, 1, 'a path that does not open leaves the queue alone');
|
|
|
|
// And the rule without a browser around it: a pass reads the queue in its own
|
|
// order unless the click says otherwise.
|
|
const wide = dir('Wide', { a: { 'A.JPG': null }, b: { 'B.JPG': null } });
|
|
const before = [];
|
|
await walkPass(at(wide, [{ dir: wide, rel: '' }]), before, [], keep, () => null);
|
|
assert.deepEqual(before.map((f) => f.rel), ['a/A.JPG', 'b/B.JPG'], 'untouched, the folders are read in the order they were found');
|
|
const after = [];
|
|
await walkPass(at(wide, [{ dir: wide, rel: '' }]), after, [], keep, () => 'b/');
|
|
assert.deepEqual(after.map((f) => f.rel), ['b/B.JPG', 'a/A.JPG'], 'the folder the reader clicked is read first');
|
|
|
|
// A pass is bounded: a library wider than the budget is read over several passes,
|
|
// and the rest waits in the queue rather than in a loop that never yields.
|
|
const children = {};
|
|
for (let i = 0; i < DIRS_PER_PASS + 5; i++) children[`d${String(i).padStart(3, '0')}`] = { 'x.jpg': null };
|
|
const many = at(dir('Many', children), [{ dir: dir('Many', children), rel: '' }]);
|
|
const found = [];
|
|
assert.equal(await walkPass(many, found, [], keep, () => null), false, 'the queue is not empty after one pass');
|
|
assert.equal(found.length, DIRS_PER_PASS - 1, `one pass read the picked folder and ${DIRS_PER_PASS - 1} below it`);
|
|
assert.equal(many.pending.length, 6, 'the rest is left in the queue, for the next pass');
|
|
|
|
console.log(`ok roll walk — ${rels.length} frames layer by layer, names a pass ahead, click jumps the queue, ${DIRS_PER_PASS} folders a pass`);
|