235 lines
9.5 KiB
JavaScript
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);
|