#!/usr/bin/env node /** * ObsiGate — Frontend unit tests for #106 (assistant contextual quick actions). * * Covers the pure logic of frontend/js/ai-quick-actions.js: * - detectContext precedence (selection > code > multi-doc > single-doc > * directory > general) * - every preset id resolves to a real action (label + prompt keys exist) * - i18n completeness: every qa.* key referenced by the catalogue exists in * BOTH fr.json and en.json (FR/EN contract from AGENTS.md) * * Usage: node tests/frontend/ai-quick-actions.test.mjs */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, "..", ".."); // Minimal browser shim: i18n.js exposes `window.t = t` at module top-level. // Strings are not loaded here, so t() falls back to the key — enough for the // pure-logic contract asserted below (keys existence is checked against the // real locale JSON files instead). globalThis.window = globalThis.window || globalThis; const { detectContext, suggestionsFor, getAction, contextBadgeKey, ACTION_CATALOG, CATEGORIES, CONTEXT_PRESETS, } = await import(pathToFileURL(path.join(ROOT, "frontend", "js", "ai-quick-actions.js")).href); let testCount = 0; let passCount = 0; async function test(name, fn) { testCount++; try { await fn(); passCount++; console.log(` ✓ ${name}`); } catch (err) { console.error(` ✗ ${name}\n ${err.message}`); process.exitCode = 1; } } // ── detectContext precedence ─────────────────────────────────────────────── await test("selection wins over every other context", () => { assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "a.md", hasSelection: true }), "selection"); assert.equal(detectContext({ mode: "documents", docCount: 3, currentPath: "x.py", hasSelection: true }), "selection"); }); await test("code file drives the code context", () => { assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "backend/main.py" }), "code"); assert.equal(detectContext({ mode: "documents", docCount: 2, currentPath: "script.SH" }), "code"); assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "app.tsx" }), "code"); }); await test("2+ documents drive the multi_doc context", () => { assert.equal(detectContext({ mode: "documents", docCount: 2, currentPath: "a.md" }), "multi_doc"); }); await test("one text document drives the single_doc context", () => { assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "note.md" }), "single_doc"); assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "plan.txt" }), "single_doc"); assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "image.png" }), "single_doc"); }); await test("directory and general fall back correctly", () => { assert.equal(detectContext({ mode: "directory", docCount: 0 }), "directory"); assert.equal(detectContext({ mode: "general", docCount: 0 }), "general"); assert.equal(detectContext(), "general"); }); // ── presets & catalogue integrity ────────────────────────────────────────── await test("every context has 3-4 suggested actions, all resolvable", () => { for (const [ctx, ids] of Object.entries(CONTEXT_PRESETS)) { assert.ok(ids.length >= 3 && ids.length <= 4, `preset ${ctx} must hold 3-4 actions`); for (const id of ids) assert.ok(getAction(id), `preset ${ctx}: unknown action id ${id}`); assert.equal(suggestionsFor(ctx).length, ids.length); } }); await test("unknown context falls back to the general preset", () => { assert.deepEqual(suggestionsFor("nonsense").map((a) => a.id), suggestionsFor("general").map((a) => a.id)); }); await test("catalogue actions have unique ids, a known category, icon and keys", () => { const catIds = new Set(CATEGORIES.map((c) => c.id)); const seen = new Set(); for (const a of ACTION_CATALOG) { assert.ok(!seen.has(a.id), `duplicate action id ${a.id}`); seen.add(a.id); assert.ok(catIds.has(a.cat), `action ${a.id}: unknown category ${a.cat}`); assert.ok(a.icon, `action ${a.id}: missing icon`); assert.ok(a.labelKey && a.promptKey, `action ${a.id}: missing i18n keys`); } }); await test("the 4 scenarios of the design table map to the expected actions", () => { assert.deepEqual(suggestionsFor("single_doc").map((a) => a.id), ["summarize_3", "checklist", "frontmatter", "frontmatter_update"]); assert.deepEqual(suggestionsFor("multi_doc").map((a) => a.id), ["merge", "compare", "frictions"]); assert.deepEqual(suggestionsFor("code").map((a) => a.id), ["explain_code", "audit_code", "test_code"]); assert.deepEqual(suggestionsFor("selection").map((a) => a.id), ["concise", "fix_style", "explain_selection"]); }); await test("#187: frontmatter actions need no agent-mode flag (always agent)", () => { assert.equal(getAction("frontmatter").agent, undefined); assert.equal(getAction("frontmatter_update").agent, undefined); }); // ── i18n completeness (FR + EN) ──────────────────────────────────────────── const FR = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8")); const EN = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8")); await test("every catalogue/preset key exists in FR and EN (label + prompt)", () => { const keys = new Set(); for (const a of ACTION_CATALOG) { keys.add(a.labelKey); keys.add(a.promptKey); } for (const id of Object.values(CONTEXT_PRESETS).flat()) { const a = getAction(id); keys.add(a.labelKey); keys.add(a.promptKey); } for (const c of CATEGORIES) keys.add(c.labelKey); for (const k of ["qa.header_suggested", "qa.all_actions", "qa.drawer_title", "qa.search_placeholder", "qa.no_match", "qa.empty_hint", "qa.badge_single", "qa.badge_multi", "qa.badge_code", "qa.badge_selection", "qa.badge_directory", "qa.badge_general"]) keys.add(k); for (const k of keys) { assert.ok(typeof FR[k] === "string" && FR[k].length > 0, `fr.json missing ${k}`); assert.ok(typeof EN[k] === "string" && EN[k].length > 0, `en.json missing ${k}`); } }); await test("context badge keys resolve per context", () => { assert.equal(contextBadgeKey("code"), "qa.badge_code"); assert.equal(contextBadgeKey("selection"), "qa.badge_selection"); assert.equal(contextBadgeKey("multi_doc"), "qa.badge_multi"); assert.equal(contextBadgeKey("single_doc"), "qa.badge_single"); assert.equal(contextBadgeKey("directory"), "qa.badge_directory"); assert.equal(contextBadgeKey("general"), "qa.badge_general"); }); await test("multi-doc badge carries the {count} param in both locales", () => { assert.ok(FR["qa.badge_multi"].includes("{count}")); assert.ok(EN["qa.badge_multi"].includes("{count}")); }); console.log(`\n${passCount} passed, ${testCount - passCount} failed`); if (passCount !== testCount) process.exit(1);