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:
night
2026-03-09 14:11:11 +03:00
committed by Petr Polezhaev
commit 5d5f26c8ae
64 changed files with 8605 additions and 0 deletions

View 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>'), '&lt;b&gt;text&lt;/b&gt;');
assert.equal(esc('"quoted"'), '&quot;quoted&quot;');
assert.equal(esc('a & b'), 'a &amp; b');
assert.equal(esc('<script>alert("xss")</script>'), '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
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')), []);
});