#!/usr/bin/env node /** * ObsiGate — Image viewer tests (roadmap #108-D). * * Pure helpers (clampImageZoom / isImagePath / buildImageUrl) are evaluated in * a VM sandbox to avoid importing the full browser-only viewer chain. The rest * are static checks on the source and CSS. * * Usage: node tests/frontend/image-viewer.test.mjs */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import vm from "node:vm"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, "..", ".."); const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8"); const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8"); const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8"); const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8"); const filesRead = readFileSync(path.join(ROOT, "backend", "routers", "files_read.py"), "utf8"); function test(label, fn) { try { fn(); console.log(" \u2713 " + label); } catch (err) { console.error(" \u2717 " + label + "\n " + String(err.message).slice(0, 300)); process.exitCode = 1; } } // ── Pure helpers (VM sandbox) ────────────────────────────────────────────── const begin = viewer.indexOf("const IMAGE_EXTS"); const end = viewer.indexOf("function formatBytes"); assert.ok(begin !== -1 && end !== -1, "image viewer helper block not found"); const sandbox = {}; vm.createContext(sandbox); vm.runInContext( `${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.clampImageZoom = clampImageZoom; globalThis.isImagePath = isImagePath; globalThis.buildImageUrl = buildImageUrl;`, sandbox, ); const { clampImageZoom, isImagePath, buildImageUrl } = sandbox; test("clampImageZoom bounds to [0.1, 8]", () => { assert.equal(clampImageZoom(0.01), 0.1); assert.equal(clampImageZoom(100), 8); assert.equal(clampImageZoom(2), 2); assert.equal(clampImageZoom(NaN), 1); }); test("isImagePath recognises image extensions (case-insensitive)", () => { for (const p of ["a.png", "a.JPG", "dir/b.jpeg", "x/y/z.svg", "p.webp", "q.bmp", "r.ico", "g.gif"]) { assert.equal(isImagePath(p), true, p); } for (const p of ["a.md", "a.txt", "noext", "a.PDF", ""]) { assert.equal(isImagePath(p), false, p); } }); test("buildImageUrl encodes vault and path", () => { const url = buildImageUrl("My Vault", "café image.png"); assert.equal(url, "/api/image/My%20Vault?path=caf%C3%A9%20image.png"); }); // ── Static checks: viewer wiring ─────────────────────────────────────────── test("viewer.js dispatches is_image to renderImageViewer", () => { assert.match(viewer, /if \(data\.is_image\) \{\s*renderImageViewer\(area, data\);/); }); test("renderImageViewer never uses the JSON raw endpoint as an image src", () => { const start = viewer.indexOf("export function renderImageViewer"); const stop = viewer.indexOf("export function renderFile"); assert.ok(start !== -1 && stop > start, "renderImageViewer block not found"); const block = viewer.slice(start, stop); assert.doesNotMatch(block, /\/raw\?path=/, "images must be served by /api/image, not /raw"); assert.match(block, /buildImageUrl\(vault, currentPath\)/); }); test("image viewer supports wheel zoom, drag pan and double-click reset", () => { assert.match(viewer, /stage\.addEventListener\("wheel"/); assert.match(viewer, /stage\.addEventListener\("pointerdown"/); assert.match(viewer, /stage\.addEventListener\("dblclick", \(e\) => \{ if \(!isArrow\(e\)\) resetView\(\); \}\)/); assert.match(viewer, /renderImageViewer/); }); test("image viewer exposes thumbnail navigation via /api/media thumb", () => { assert.match(viewer, /\/api\/media\/\$\{encodeURIComponent\(vault\)\}\/thumb\?path=/); assert.match(viewer, /let siblings = \[\]/); // #111 — the sibling list comes from the memoised directory listing. assert.match(viewer, /listImageSiblings/); }); // BUG-072 — lightbox + metadata panel keep their state; #111 — navigation is // in place, so the viewer (and its filmstrip) is no longer rebuilt. test("image viewer persists lightbox + metadata state across navigation", () => { assert.match(viewer, /const _imageViewerState = \{ lightbox: false, meta: false \}/); // renderFile only resets the viewer state when the next render is not an image. assert.match(viewer, /if \(!data\.is_image\) \{\s*\n\s*_imageViewerState\.lightbox = false;/); // The persisted state drives the container class and the panel visibility. assert.match(viewer, /classList\.toggle\("lightbox", _imageViewerState\.lightbox\)/); assert.match(viewer, /metaPanel\.hidden = !_imageViewerState\.meta/); }); // #111 — switching siblings swaps the in place instead of re-rendering // (no openFile round-trip, filmstrip stays in the DOM, neighbours preloaded). test("image viewer navigates between siblings in place (#111)", () => { assert.doesNotMatch(viewer, /_imageViewerNavPending/); assert.match(viewer, /const showSibling = \(index\) =>/); assert.match(viewer, /const go = \(delta\) => \{\s*\n\s*if \(siblings\.length < 2[^)]*\) return;\s*\n\s*showSibling\(/); // Thumbnail clicks and the overlay arrows both call showSibling/go in place. assert.match(viewer, /thumb\.addEventListener\("click", \(\) => showSibling\(i\)\)/); assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-prev"/); assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-next"/); // The active thumbnail is only re-highlighted / scrolled, never rebuilt. assert.match(viewer, /updateStripActive/); assert.match(viewer, /const preload = \(i\) =>/); // Directory listing is cached for a short TTL. assert.match(viewer, /const _imageDirCache = new Map\(\)/); }); // #112 — the filmstrip scrolls horizontally (mouse wheel + two edge arrows). test("image viewer filmstrip scrolls with the wheel and edge arrows (#112)", () => { assert.match(viewer, /const navWrap = el\("div", \{ class: "image-nav", hidden: true \}\)/); assert.match(viewer, /image-strip-arrow-prev/); assert.match(viewer, /image-strip-arrow-next/); assert.match(viewer, /strip\.addEventListener\("wheel"/); assert.match(viewer, /strip\.scrollBy\(\{ left: dir/); assert.match(viewer, /navWrap\.hidden = true/); assert.match(viewer, /navWrap\.hidden = false/); assert.match(css, /\.image-nav\s*\{[\s\S]*?position:\s*relative/); assert.match(css, /\.image-strip-arrow\s*\{/); assert.match(css, /\.image-strip-arrow-prev\s*\{\s*left:\s*0/); assert.match(css, /\.image-strip-arrow-next\s*\{\s*right:\s*0/); }); // ── Static checks: CSS ───────────────────────────────────────────────────── test("style.css defines the image viewer layout + lightbox", () => { assert.match(css, /\.image-stage\s*\{/); assert.match(css, /\.image-nav-strip\s*\{/); assert.match(css, /\.image-meta-panel\s*\{/); assert.match(css, /\.image-viewer-container\.lightbox\s*\{/); }); // #111 — the viewer fills the content box and the image is capped to the frame. test("style.css resizes the image to the available frame (#111)", () => { assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*padding:\s*0/); assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*overflow:\s*hidden/); const main = /\.image-main\s*\{([^}]*)\}/.exec(css); assert.ok(main, ".image-main rule not found"); assert.match(main[1], /max-width:\s*100%/); assert.match(main[1], /max-height:\s*100%/); }); // #111 — translucent overlay arrows on both frame edges, revealed on hover. test("style.css styles the overlay navigation arrows (#111)", () => { assert.match(css, /\.image-nav-arrow\s*\{/); assert.match(css, /\.image-nav-arrow-prev\s*\{\s*left:\s*0/); assert.match(css, /\.image-nav-arrow-next\s*\{\s*right:\s*0/); const arrow = /\.image-nav-arrow\s*\{([^}]*)\}/.exec(css); assert.ok(arrow, ".image-nav-arrow rule not found"); assert.match(arrow[1], /opacity:\s*0/); assert.match(css, /\.image-nav-arrow:hover[\s\S]*?opacity:\s*1/); }); // BUG-072 — metadata is a right-hand sidebar, not a strip under the filmstrip, // and it stays visible in lightbox mode. test("style.css lays the metadata panel out as a right sidebar", () => { assert.match(css, /\.image-viewer-body\s*\{[\s\S]*?display:\s*flex/); const meta = /\.image-meta-panel\s*\{([^}]*)\}/.exec(css); assert.ok(meta, ".image-meta-panel rule not found"); assert.match(meta[1], /border-left:/); assert.doesNotMatch(meta[1], /border-top:/); // The lightbox hide rule targets the filmstrip only — not the metadata sidebar. assert.match(css, /\.image-viewer-container\.lightbox \.image-nav-strip\s*\{/); assert.doesNotMatch(css, /\.image-viewer-container\.lightbox[^{]*\.image-meta-panel/); }); // ── Static checks: icon mapping + backend ────────────────────────────────── test("utils.js maps image extensions to the Lucide 'image' icon", () => { assert.match(utils, /"\.png": "image"/); assert.match(utils, /"\.svg": "image"/); }); test("backend api_file_view points at /api/image", () => { // #85 T6a : le handler vit dans backend/routers/files_read.py assert.match(filesRead, /img_url = f"\/api\/image\//); assert.match(filesRead, /f'