Initial commit
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.
This commit is contained in:
239
tests/js/pure-functions.test.js
Normal file
239
tests/js/pure-functions.test.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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')), []);
|
||||
});
|
||||
Reference in New Issue
Block a user