Files
photoanalyzer/frontend/js/tests/unit.js
domverse 457dd5bd90 US02-06: Deliver Workflow, Safety, Library, Analysis, and Stats Views
Ports the safety review and the Photo Analyzer Library/Analyze/Stats
experiences onto the shared API + service layer, and adds the Workflow
home, enforcing the pipeline gates and the one-mutating-job policy.

Backend
- migration 0005 + models: safety_reviews (append-only, latest row is the
  current decision) and analysis_results (donor photos schema re-keyed to
  asset_id).
- SafetyService: persist scores/decisions, review queue with filters, and
  the EXIF safety checkpoint (mutually-exclusive sfw/nsfw keyword written,
  read back, current_sha256 refreshed) that upload eligibility depends on.
- AnalysisService: the privacy gate — the vision provider is called ONLY for
  canonical, confirmed-SFW assets; nsfw/undecided are recorded skipped without
  a request. Provider is an injected adapter (real OpenAI-compatible Gemini
  call extracted from photo_analyzer.analyze_image; a fake in tests).
- LibraryService: Library search + Stats read model ported from webapp/query.py
  (LIKE search in place of FTS5; facets, top tags, years, albums, people).
- WorkflowService + GET /api/v1/workflow: per-stage readiness derived from the
  source tables — counts, blockers, last-run, action, and an active_job that
  drives read-only-during-jobs. Safety scoring and analysis run as durable jobs
  under the library_write lock via new domain handlers, so a second mutating
  job is refused.
- routes: workflow, safety (queue/counts/decisions/jobs), analysis
  (counts/results/jobs), library (assets/facets/stats).

Frontend
- five views (frontend/js/views.js) on the US02-05 shell: Workflow stepper
  (status text+icon, not colour alone; actions disabled with a reason while a
  job runs), Safety review (filter tabs, decide, persists across reload),
  Library (search + cards), Analyze (counts + live job log via the SSE
  adapter), Stats. Shared DOM helpers extracted to dom.js; Workflow is the home
  route.

Tests
- integration: provider-call privacy (nsfw never reaches the provider),
  sfw→nsfw flip drops analysis eligibility, decision persistence, one-mutating-
  job rejection, workflow counts, and the exiftool safety-keyword write/verify.
- e2e: Workflow cards, actions disabled+explained during a job, safety
  decide-persists-across-reload, Library search, Stats, Analyze counts.
- traceability map updated for US02-05 and US02-06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:52:01 +02:00

151 lines
5.5 KiB
JavaScript

// In-browser unit tests for the shell primitives. No server, no JS toolchain:
// globals (fetch, EventSource) are stubbed and the modules are exercised directly.
// Driven by tests/e2e/test_frontend_shell.py, which loads harness.html and reads
// window.__RESULTS__. Covers API errors + cancellation, store transitions,
// routing, and the SSE→polling event fallback (US02-05 acceptance).
import { createStore } from "../store.js";
import { parseHash, navigate } from "../router.js";
import { api, cancellable } from "../api.js";
import { subscribeJob } from "../events.js";
const cases = [];
function ok(name, cond) {
cases.push({ name, ok: !!cond });
}
async function throws(name, fn, predicate) {
try {
await fn();
cases.push({ name, ok: false });
} catch (error) {
cases.push({ name, ok: !!predicate(error) });
}
}
const realFetch = window.fetch;
const realES = window.EventSource;
function jsonResponse(status, body) {
return { ok: status < 400, status, statusText: "", json: async () => body };
}
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
async function run() {
// ── store ────────────────────────────────────────────────────────────────
{
const store = createStore({ n: 0 });
const seen = [];
const off = store.subscribe((s) => seen.push(s.n));
store.set({ n: 1 });
store.set((s) => ({ n: s.n + 1 }));
ok("store applies object and updater patches", store.get().n === 2);
ok("store notifies each change", seen.length === 2 && seen[1] === 2);
off();
store.set({ n: 99 });
ok("store unsubscribe stops notifications", seen.length === 2);
}
// ── router ───────────────────────────────────────────────────────────────
{
navigate("/inventory", { q: "beach", offset: 0, empty: "" });
const { path, params } = parseHash();
ok("router restores path", path === "/inventory");
ok("router restores non-empty filters", params.q === "beach" && params.offset === "0");
ok("router drops empty filters", !("empty" in params));
location.hash = "";
ok("router defaults to /workflow", parseHash().path === "/workflow");
}
// ── api errors + cancellation ────────────────────────────────────────────
{
window.fetch = async () =>
jsonResponse(404, { error: { code: "not_found", message: "unknown job x" } });
await throws(
"api surfaces the error envelope",
() => api.getJob("x"),
(e) => e.status === 404 && e.code === "not_found" && e.message === "unknown job x"
);
window.fetch = async () => {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
};
const { signal, cancel } = cancellable();
cancel();
await throws(
"api tags aborted requests as cancelled",
() => api.getJob("x", { signal }),
(e) => e.code === "cancelled"
);
window.fetch = realFetch;
}
// ── event fallback: polling when EventSource is unavailable ───────────────
{
window.EventSource = undefined;
const pages = [
jsonResponse(200, { state: "running", events: [{ seq: 1, type: "queued", message: null }] }),
jsonResponse(200, {
state: "succeeded",
events: [{ seq: 2, type: "state:succeeded", message: null }],
}),
];
let call = 0;
window.fetch = async () => pages[Math.min(call++, pages.length - 1)];
const got = [];
await new Promise((resolve) => {
subscribeJob("job1", {
pollMs: 1,
onEvent: (e) => got.push(e.seq),
onDone: resolve,
});
});
ok("polling delivers events in order", got.join(",") === "1,2");
ok("polling stops on terminal state", call >= 2);
window.fetch = realFetch;
}
// ── event stream: SSE path, then done ─────────────────────────────────────
{
class FakeES {
constructor() {
this.listeners = {};
setTimeout(() => {
this.onmessage?.({ lastEventId: "5", data: JSON.stringify({ type: "state:running", message: "go" }) });
this.listeners.done?.();
}, 0);
}
addEventListener(type, fn) {
this.listeners[type] = fn;
}
close() {
this.closed = true;
}
}
window.EventSource = FakeES;
let polled = false;
window.fetch = async () => {
polled = true;
return jsonResponse(200, { state: "succeeded", events: [] });
};
const got = [];
await new Promise((resolve) => {
subscribeJob("job2", { onEvent: (e) => got.push(e), onDone: resolve });
});
ok("SSE delivers event with seq from lastEventId", got.length === 1 && got[0].seq === 5);
ok("SSE carries type from payload", got[0].type === "state:running");
ok("SSE path does not poll", polled === false);
window.fetch = realFetch;
window.EventSource = realES;
}
await tick();
const failed = cases.filter((c) => !c.ok);
window.__RESULTS__ = { passed: cases.length - failed.length, failed: failed.length, cases };
const pre = document.createElement("pre");
pre.id = "results";
pre.textContent = cases.map((c) => `${c.ok ? "PASS" : "FAIL"} ${c.name}`).join("\n");
document.body.appendChild(pre);
}
run();