Files
ObsiGate/tests/frontend/image-viewer.test.mjs
T
bruno a726ad8511
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m29s
CI / test (push) Successful in 4m59s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 13m49s
feat: en-tete allege, compte utilisateur en sidebar et pellicule d'images defilable (molette + fleches) #112
2026-09-23 21:06:41 -04:00

198 lines
9.5 KiB
JavaScript

#!/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");
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 <img> 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 <img> at /api/image", () => {
assert.match(main, /img_url = f"\/api\/image\//);
assert.match(main, /f'<img src="\{img_url\}"/);
});
if (process.exitCode) {
console.error("\nImage viewer tests FAILED");
} else {
console.log("\nAll image viewer tests passed.");
}