Files
ObsiGate/tests/frontend/forge-completion.test.mjs
T
bruno 3758db2861
CI / lint (push) Successful in 1m33s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m7s
CI / build (push) Successful in 57s
CI / e2e (push) Successful in 10m56s
fix(forge): quitter le plein ecran avant d'ouvrir l'assistant IA (BUG-056)
2026-09-17 09:07:00 -04:00

235 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
/**
* ObsiGate — Forge autocomplete tests (BUG-055).
*
* The Forge editor (`frontend/editor-poc.html`) shares its pure completion
* helpers with CodeMirror through `frontend/js/autocomplete.js`. This suite
* pins the behaviour that fixes the « Tab adds a stray space » bug:
*
* - getWordFragment / findWordCompletions — document word completion
* - normalizeGhost — AI inline prediction cleanup
* - chooseTabAction — single action per Tab press
*
* Usage: node tests/frontend/forge-completion.test.mjs
*/
import { strict as assert } from "node:assert";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const mod = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "autocomplete.js")).href
);
const {
isWordChar,
getWordFragment,
findWordCompletions,
normalizeGhost,
chooseTabAction,
} = mod;
let testCount = 0;
let failCount = 0;
function test(name, fn) {
testCount++;
try {
fn();
console.log(` ✓ ${name}`);
} catch (err) {
failCount++;
console.error(` ✗ ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
// ── isWordChar ─────────────────────────────────────────────────────────────
test("isWordChar accepts letters, digits, _ - . /", () => {
for (const ch of ["a", "Z", "0", "_", "-", ".", "/"]) {
assert.equal(isWordChar(ch), true, `expected ${JSON.stringify(ch)} to be a word char`);
}
});
test("isWordChar rejects spaces and punctuation", () => {
for (const ch of [" ", "\n", "!", "@", "#", "(", "\t"]) {
assert.equal(isWordChar(ch), false, `expected ${JSON.stringify(ch)} not to be a word char`);
}
assert.equal(isWordChar(""), false);
assert.equal(isWordChar(undefined), false);
});
// ── getWordFragment ────────────────────────────────────────────────────────
test("getWordFragment returns the fragment before the cursor", () => {
const text = "hello wor";
assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "wor" });
});
test("getWordFragment stops at non-word characters", () => {
const text = "a (tabl";
assert.deepEqual(getWordFragment(text, text.length), { start: 3, fragment: "tabl" });
});
test("getWordFragment returns an empty fragment at a word boundary", () => {
const text = "hello ";
assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "" });
});
test("getWordFragment handles a mid-document cursor", () => {
const text = "one two three";
// cursor right after "tw"
assert.deepEqual(getWordFragment(text, 6), { start: 4, fragment: "tw" });
});
// ── findWordCompletions ────────────────────────────────────────────────────
test("findWordCompletions returns prefix matches from the document", () => {
const text = "table tableau tab\n";
const cursor = text.length;
assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table", "tableau"]);
});
test("findWordCompletions ignores the occurrence being typed", () => {
const text = "table and tab";
const cursor = text.length; // typing the trailing "tab"
assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table"]);
});
test("findWordCompletions is case-insensitive and unique", () => {
const text = "Table TABLE table\n";
const cursor = text.length;
assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["Table"]);
});
test("findWordCompletions completes file-like tokens", () => {
const text = "docs/guide.md docs/guide\n";
const cursor = text.length;
assert.deepEqual(findWordCompletions(text, cursor, "docs/", 8), ["docs/guide.md", "docs/guide"]);
});
test("findWordCompletions honours the limit", () => {
const text = "abc abd abe abf abg\n";
const cursor = text.length;
assert.equal(findWordCompletions(text, cursor, "ab", 3).length, 3);
});
test("findWordCompletions needs at least two characters", () => {
const text = "apple apricot\n";
assert.deepEqual(findWordCompletions(text, text.length, "a", 8), []);
assert.deepEqual(findWordCompletions(text, text.length, "", 8), []);
});
// ── normalizeGhost ─────────────────────────────────────────────────────────
test("normalizeGhost strips an echoed prefix (mid-word)", () => {
assert.equal(normalizeGhost("familial", "famil", true), "ial");
});
test("normalizeGhost keeps only the first token for a word completion", () => {
// A word completion must never introduce a space.
assert.equal(normalizeGhost("familial and more", "famil", true), "ial");
});
test("normalizeGhost tolerates a leading space from the model", () => {
assert.equal(normalizeGhost(" monde", "bonjour", false), "monde");
});
test("normalizeGhost strips a repeated full context", () => {
assert.equal(normalizeGhost("Bonjour le monde", "Bonjour", false), " le monde".trim());
});
test("normalizeGhost returns an empty string for empty input", () => {
assert.equal(normalizeGhost("", "abc", true), "");
assert.equal(normalizeGhost(null, "abc", true), "");
assert.equal(normalizeGhost(" ", "abc", false), "");
});
test("normalizeGhost keeps a phrase continuation intact", () => {
assert.equal(normalizeGhost("la suite de la phrase.", "Voici", false), "la suite de la phrase.");
});
// ── chooseTabAction ────────────────────────────────────────────────────────
test("chooseTabAction prioritises the open list", () => {
assert.equal(chooseTabAction({ dropdownOpen: true, ghost: true }), "dropdown");
});
test("chooseTabAction accepts a ghost prediction before word completion", () => {
assert.equal(chooseTabAction({ ghost: true, candidates: ["table"] }), "ghost");
});
test("chooseTabAction inserts a single word match", () => {
assert.equal(chooseTabAction({ candidates: ["table"] }), "word");
});
test("chooseTabAction opens the list for several matches", () => {
assert.equal(chooseTabAction({ candidates: ["table", "tableau"] }), "word-list");
});
test("chooseTabAction indents when nothing matches", () => {
assert.equal(chooseTabAction({ candidates: [] }), "indent");
assert.equal(chooseTabAction({}), "indent");
});
test("chooseTabAction dedents on Shift+Tab", () => {
assert.equal(chooseTabAction({ shiftKey: true }), "dedent");
assert.equal(chooseTabAction({ shiftKey: true, candidates: ["table"] }), "dedent");
});
test("chooseTabAction indents when a selection exists", () => {
assert.equal(chooseTabAction({ hasSelection: true, candidates: ["table"] }), "indent");
});
// ── Static wiring of frontend/editor-poc.html ──────────────────────────────
import { readFileSync } from "node:fs";
const FORGE_HTML = readFileSync(
path.join(REPO_ROOT, "frontend", "editor-poc.html"),
"utf-8",
);
test("Forge uses the shared completion helpers", () => {
for (const call of ["ac.getWordFragment(", "ac.findWordCompletions(", "ac.chooseTabAction("]) {
assert.ok(FORGE_HTML.includes(call), `editor-poc.html must call ${call}`);
}
});
test("Forge has a single indentation call site (no double Tab handling)", () => {
const indentCalls = FORGE_HTML.match(/indentLine\(\);/g) || [];
assert.equal(indentCalls.length, 1, "indentLine() must only be called from the unified Tab handler");
});
test("Forge ghost text no longer mirrors the whole document", () => {
assert.ok(!FORGE_HTML.includes("ghostOverlay.innerHTML"), "the mirror overlay must be gone");
});
test("Forge ignores its own autosave reload while the buffer is dirty (BUG-055)", () => {
assert.ok(
FORGE_HTML.includes("if (!e.data.force && isDirty) return;"),
"parent-reload must not clobber unsaved local edits",
);
});
test("Forge cancels a pending ghost request when the prediction is accepted", () => {
const fn = FORGE_HTML.match(/function acceptGhost\(\) \{([\s\S]*?)\n \}/);
assert.ok(fn, "acceptGhost not found");
assert.match(fn[1], /clearTimeout\(_ghostTimer\)/);
});
test("Forge autosave keeps the buffer dirty if edits arrive during the request", () => {
const fn = FORGE_HTML.match(/function autoSave\(\) \{([\s\S]*?)\n \}/);
assert.ok(fn, "autoSave not found");
assert.match(fn[1], /if \(val\(\) !== content\) \{ scheduleAutoSave\(\); return; \}/);
});
test("Forge leaves fullscreen before opening the shared assistant", () => {
const fn = FORGE_HTML.match(/function openAssistant\(\) \{([\s\S]*?)\n \}/);
assert.ok(fn, "openAssistant not found");
assert.match(fn[1], /document\.fullscreenElement/);
assert.match(fn[1], /document\.exitFullscreen\(\)/);
assert.match(fn[1], /postMessage\(\{ type: 'forge-open-ai' \}, '\*'\)/);
});
// ── Summary ────────────────────────────────────────────────────────────────
console.log(`\n${testCount} passed, ${failCount} failed`);
process.exit(failCount > 0 ? 1 : 0);