/** * 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('text'), '<b>text</b>'); assert.equal(esc('"quoted"'), '"quoted"'); assert.equal(esc('a & b'), 'a & b'); assert.equal(esc(''), '<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')), []); });