Photo-based book cataloger with AI identification. Room → Cabinet → Shelf → Book hierarchy; FastAPI + SQLite backend; vanilla JS SPA; OpenAI-compatible plugin system for boundary detection, text recognition, and archive search.
240 lines
8.5 KiB
JavaScript
240 lines
8.5 KiB
JavaScript
/**
|
|
* pure-functions.test.js
|
|
* Unit tests for pure / side-effect-free functions extracted from static/js/*.
|
|
*
|
|
* Strategy: use node:vm runInNewContext to execute each browser script in a
|
|
* fresh sandbox. Function declarations at the top level of a script become
|
|
* properties of the sandbox context object, which is what we assert against.
|
|
* Files that reference the DOM at load-time (photo.js) receive a minimal stub.
|
|
*/
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { runInNewContext } from 'node:vm';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { join, dirname } from 'node:path';
|
|
|
|
// Values returned from VM sandboxes live in a different V8 realm, so
|
|
// deepStrictEqual rejects them even when structurally identical.
|
|
// JSON round-trip moves them into the current realm before comparison.
|
|
const j = (v) => JSON.parse(JSON.stringify(v));
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
|
|
/**
|
|
* Load a browser script into a fresh VM sandbox and return the sandbox.
|
|
* A minimal DOM stub is merged with `extra` so top-level DOM calls don't throw.
|
|
*/
|
|
function load(relPath, extra = {}) {
|
|
const code = readFileSync(join(ROOT, relPath), 'utf8');
|
|
const el = {
|
|
textContent: '', value: '', files: [], style: {},
|
|
classList: { add() {}, remove() {} },
|
|
setAttribute() {}, removeAttribute() {}, click() {}, addEventListener() {},
|
|
};
|
|
const ctx = {
|
|
document: { getElementById: () => el, querySelector: () => null, querySelectorAll: () => [] },
|
|
window: { innerWidth: 800 },
|
|
navigator: { userAgent: '' },
|
|
clearTimeout() {}, setTimeout() {},
|
|
...extra,
|
|
};
|
|
runInNewContext(code, ctx);
|
|
return ctx;
|
|
}
|
|
|
|
// ── esc (helpers.js) ─────────────────────────────────────────────────────────
|
|
|
|
test('esc: escapes HTML special characters', () => {
|
|
const { esc } = load('static/js/helpers.js');
|
|
assert.equal(esc('<b>text</b>'), '<b>text</b>');
|
|
assert.equal(esc('"quoted"'), '"quoted"');
|
|
assert.equal(esc('a & b'), 'a & b');
|
|
assert.equal(esc('<script>alert("xss")</script>'), '<script>alert("xss")</script>');
|
|
});
|
|
|
|
test('esc: coerces null/undefined/number to string', () => {
|
|
const { esc } = load('static/js/helpers.js');
|
|
assert.equal(esc(null), '');
|
|
assert.equal(esc(undefined), '');
|
|
assert.equal(esc(42), '42');
|
|
});
|
|
|
|
// ── parseBounds (canvas-boundary.js) ─────────────────────────────────────────
|
|
|
|
test('parseBounds: parses valid JSON array of fractions', () => {
|
|
const { parseBounds } = load('static/js/canvas-boundary.js');
|
|
assert.deepEqual(j(parseBounds('[0.25, 0.5, 0.75]')), [0.25, 0.5, 0.75]);
|
|
assert.deepEqual(j(parseBounds('[]')), []);
|
|
});
|
|
|
|
test('parseBounds: returns [] for falsy / invalid / null-JSON input', () => {
|
|
const { parseBounds } = load('static/js/canvas-boundary.js');
|
|
assert.deepEqual(j(parseBounds(null)), []);
|
|
assert.deepEqual(j(parseBounds('')), []);
|
|
assert.deepEqual(j(parseBounds('not-json')), []);
|
|
assert.deepEqual(j(parseBounds('null')), []);
|
|
});
|
|
|
|
// ── parseBndPluginResults (canvas-boundary.js) ────────────────────────────────
|
|
|
|
test('parseBndPluginResults: parses a valid JSON object', () => {
|
|
const { parseBndPluginResults } = load('static/js/canvas-boundary.js');
|
|
assert.deepEqual(
|
|
j(parseBndPluginResults('{"p1":[0.3,0.6],"p2":[0.4]}')),
|
|
{ p1: [0.3, 0.6], p2: [0.4] }
|
|
);
|
|
});
|
|
|
|
test('parseBndPluginResults: returns {} for null / array / invalid input', () => {
|
|
const { parseBndPluginResults } = load('static/js/canvas-boundary.js');
|
|
assert.deepEqual(j(parseBndPluginResults(null)), {});
|
|
assert.deepEqual(j(parseBndPluginResults('')), {});
|
|
assert.deepEqual(j(parseBndPluginResults('[1,2,3]')), {}); // arrays are rejected
|
|
assert.deepEqual(j(parseBndPluginResults('{bad}')), {});
|
|
});
|
|
|
|
// ── parseCandidates (tree-render.js) ──────────────────────────────────────────
|
|
|
|
/** Load tree-render.js with stubs for all globals it references in function bodies. */
|
|
function loadTreeRender() {
|
|
return load('static/js/tree-render.js', {
|
|
S: { selected: null, expanded: new Set(), _loading: {} },
|
|
_plugins: [],
|
|
_batchState: { running: false, done: 0, total: 0 },
|
|
_bnd: null,
|
|
esc: (s) => String(s ?? ''),
|
|
isDesktop: () => true,
|
|
findNode: () => null,
|
|
vDetailBody: () => '',
|
|
});
|
|
}
|
|
|
|
test('parseCandidates: parses a valid JSON array', () => {
|
|
const { parseCandidates } = loadTreeRender();
|
|
const input = [{ title: 'Foo', author: 'Bar', source: 'vlm' }];
|
|
assert.deepEqual(j(parseCandidates(JSON.stringify(input))), input);
|
|
});
|
|
|
|
test('parseCandidates: returns [] for null / empty / invalid input', () => {
|
|
const { parseCandidates } = loadTreeRender();
|
|
assert.deepEqual(j(parseCandidates(null)), []);
|
|
assert.deepEqual(j(parseCandidates('')), []);
|
|
assert.deepEqual(j(parseCandidates('bad json')), []);
|
|
});
|
|
|
|
// ── getBookStats (tree-render.js) ─────────────────────────────────────────────
|
|
|
|
function makeBook(status) {
|
|
return { id: Math.random(), identification_status: status, title: 'T' };
|
|
}
|
|
|
|
test('getBookStats: counts books by status on a shelf', () => {
|
|
const { getBookStats } = loadTreeRender();
|
|
const shelf = {
|
|
id: 1,
|
|
books: [
|
|
makeBook('user_approved'),
|
|
makeBook('ai_identified'),
|
|
makeBook('unidentified'),
|
|
makeBook('unidentified'),
|
|
],
|
|
};
|
|
const s = getBookStats(shelf, 'shelf');
|
|
assert.equal(s.total, 4);
|
|
assert.equal(s.approved, 1);
|
|
assert.equal(s.ai, 1);
|
|
assert.equal(s.unidentified, 2);
|
|
});
|
|
|
|
test('getBookStats: aggregates across a full room → cabinet → shelf hierarchy', () => {
|
|
const { getBookStats } = loadTreeRender();
|
|
const room = {
|
|
id: 1,
|
|
cabinets: [{
|
|
id: 2,
|
|
shelves: [{
|
|
id: 3,
|
|
books: [makeBook('user_approved'), makeBook('unidentified'), makeBook('ai_identified')],
|
|
}],
|
|
}],
|
|
};
|
|
const s = getBookStats(room, 'room');
|
|
assert.equal(s.total, 3);
|
|
assert.equal(s.approved, 1);
|
|
assert.equal(s.ai, 1);
|
|
assert.equal(s.unidentified, 1);
|
|
});
|
|
|
|
test('getBookStats: returns zeros for a book node itself', () => {
|
|
const { getBookStats } = loadTreeRender();
|
|
const book = makeBook('user_approved');
|
|
const s = getBookStats(book, 'book');
|
|
assert.equal(s.total, 1);
|
|
assert.equal(s.approved, 1);
|
|
});
|
|
|
|
// ── collectQueueBooks (photo.js) ──────────────────────────────────────────────
|
|
|
|
function loadPhoto() {
|
|
return load('static/js/photo.js', {
|
|
S: { _photoTarget: null },
|
|
_photoQueue: null,
|
|
req: async () => ({}),
|
|
toast: () => {},
|
|
walkTree: () => {},
|
|
findNode: () => null,
|
|
isDesktop: () => true,
|
|
render: () => {},
|
|
});
|
|
}
|
|
|
|
test('collectQueueBooks: excludes user_approved books from a shelf', () => {
|
|
const { collectQueueBooks } = loadPhoto();
|
|
const shelf = {
|
|
id: 1,
|
|
books: [
|
|
{ id: 2, identification_status: 'user_approved', title: 'A' },
|
|
{ id: 3, identification_status: 'unidentified', title: 'B' },
|
|
{ id: 4, identification_status: 'ai_identified', title: 'C' },
|
|
],
|
|
};
|
|
const result = collectQueueBooks(shelf, 'shelf');
|
|
assert.equal(result.length, 2);
|
|
assert.deepEqual(j(result.map((b) => b.id)), [3, 4]);
|
|
});
|
|
|
|
test('collectQueueBooks: collects across room → cabinet → shelf hierarchy', () => {
|
|
const { collectQueueBooks } = loadPhoto();
|
|
const room = {
|
|
id: 1,
|
|
cabinets: [{
|
|
id: 2,
|
|
shelves: [{
|
|
id: 3,
|
|
books: [
|
|
{ id: 4, identification_status: 'user_approved' },
|
|
{ id: 5, identification_status: 'unidentified' },
|
|
{ id: 6, identification_status: 'ai_identified' },
|
|
],
|
|
}],
|
|
}],
|
|
};
|
|
const result = collectQueueBooks(room, 'room');
|
|
assert.equal(result.length, 2);
|
|
assert.deepEqual(j(result.map((b) => b.id)), [5, 6]);
|
|
});
|
|
|
|
test('collectQueueBooks: returns empty array when all books are approved', () => {
|
|
const { collectQueueBooks } = loadPhoto();
|
|
const shelf = {
|
|
id: 1,
|
|
books: [
|
|
{ id: 2, identification_status: 'user_approved' },
|
|
{ id: 3, identification_status: 'user_approved' },
|
|
],
|
|
};
|
|
assert.deepEqual(j(collectQueueBooks(shelf, 'shelf')), []);
|
|
});
|