- #83: ruban horizontal defilable ancre au-dessus du clavier, commandes etendues (titres, barre, surligne, code bloc, citation, listes, cases, wikilinks, tags, pieces jointes, undo/redo, colle, police) et personnalisation persistee via l'icone engrenage - BUG-017: cartes du tableau de bord compressibles (min-width:0) - BUG-018: en-tete editeur mobile compact, boutons Annuler/Sauvegarder accessibles, barre IA plus tactile - BUG-019: ancrage du ruban via visualViewport (--kb-offset) - BUG-020: suppression de la double barre de defilement Tests: mobile-editor 35/35, ai 50/50, 9 suites JSDOM, validate-imports, pytest 961 passed / 6 skipped, ruff + mypy 0.
331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ObsiGate — JSDOM tests for the native mobile editor UX (ROADMAP #69).
|
|
*
|
|
* Covers the pure helpers of frontend/js/mobile-editor.js (Markdown formatting,
|
|
* swipe detection, zoom clamping) and the DOM wiring (floating toolbar, reading
|
|
* mode toggling, bottom sheet construction). The swipe/navigation network paths
|
|
* are exercised manually / in E2E.
|
|
*
|
|
* Usage: node tests/frontend/mobile-editor.test.mjs
|
|
*/
|
|
|
|
import { strict as assert } from "node:assert";
|
|
import { JSDOM } from "jsdom";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import path from "node:path";
|
|
import { state } from "../../frontend/js/state.js";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
|
|
// ── JSDOM bootstrap ────────────────────────────────────────────────────────
|
|
const dom = new JSDOM(
|
|
`<!DOCTYPE html><html><body>
|
|
<div id="content-area"><div class="md-content"></div></div>
|
|
<div class="editor-modal active" id="editor-modal">
|
|
<div class="editor-container">
|
|
<div class="editor-body editor-body-cm" id="editor-body">
|
|
<div class="cm-editor"><div class="cm-scroller"></div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body></html>`,
|
|
{ url: "https://example.com/", pretendToBeVisual: true },
|
|
);
|
|
|
|
const w = dom.window;
|
|
globalThis.window = w;
|
|
globalThis.document = w.document;
|
|
Object.defineProperty(globalThis, "navigator", {
|
|
value: w.navigator,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
globalThis.localStorage = w.localStorage;
|
|
globalThis.MutationObserver = w.MutationObserver;
|
|
|
|
const mod = await import(
|
|
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "mobile-editor.js")).href
|
|
);
|
|
|
|
const {
|
|
FORMAT_ACTIONS,
|
|
RIBBON_COMMANDS,
|
|
DEFAULT_RIBBON_COMMANDS,
|
|
MIN_EDITOR_FONT,
|
|
MAX_EDITOR_FONT,
|
|
clampZoom,
|
|
detectSwipe,
|
|
formatChange,
|
|
normalizeRibbonCommands,
|
|
moveRibbonCommand,
|
|
insertText,
|
|
applyFormatToTextarea,
|
|
applyEditorFontSize,
|
|
initMobileEditor,
|
|
toggleReadingMode,
|
|
isReadingMode,
|
|
updateReadingButtonVisibility,
|
|
} = mod;
|
|
|
|
let testCount = 0;
|
|
let passCount = 0;
|
|
|
|
function test(name, fn) {
|
|
testCount++;
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
passCount++;
|
|
} catch (e) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` ${e.message}`);
|
|
}
|
|
}
|
|
|
|
console.log("\n── mobile-editor.js unit tests (#69) ──\n");
|
|
|
|
// ── Pure helpers ───────────────────────────────────────────────────────────
|
|
test("FORMAT_ACTIONS exposes the five expected actions", () => {
|
|
assert.deepEqual(FORMAT_ACTIONS, ["bold", "italic", "code", "list", "link"]);
|
|
});
|
|
|
|
test("clampZoom clamps to [min, max] and falls back to 13.5", () => {
|
|
assert.equal(clampZoom(5), MIN_EDITOR_FONT);
|
|
assert.equal(clampZoom(100), MAX_EDITOR_FONT);
|
|
assert.equal(clampZoom(16), 16);
|
|
assert.equal(clampZoom("abc"), 13.5);
|
|
assert.equal(clampZoom(NaN), 13.5);
|
|
});
|
|
|
|
test("detectSwipe returns null below the threshold", () => {
|
|
assert.equal(detectSwipe(20, 0), null);
|
|
assert.equal(detectSwipe(-20, 0), null);
|
|
});
|
|
|
|
test("detectSwipe ignores vertical-dominant gestures", () => {
|
|
assert.equal(detectSwipe(10, 120), null);
|
|
});
|
|
|
|
test("detectSwipe detects left and right horizontal swipes", () => {
|
|
assert.equal(detectSwipe(-90, 5), "left");
|
|
assert.equal(detectSwipe(90, -5), "right");
|
|
});
|
|
|
|
test("formatChange bold wraps the selection", () => {
|
|
const c = formatChange("hello world", 0, 5, "bold");
|
|
assert.equal(c.changes.insert, "**hello**");
|
|
assert.equal(c.changes.from, 0);
|
|
assert.equal(c.changes.to, 5);
|
|
assert.deepEqual(c.selection, { anchor: 2, head: 7 });
|
|
});
|
|
|
|
test("formatChange bold with no selection inserts markers and selects inside", () => {
|
|
const c = formatChange("abc", 1, 1, "bold");
|
|
assert.equal(c.changes.insert, "****");
|
|
assert.deepEqual(c.selection, { anchor: 3, head: 3 });
|
|
});
|
|
|
|
test("formatChange bold toggles off when markers are selected", () => {
|
|
const c = formatChange("**hi**", 0, 6, "bold");
|
|
assert.equal(c.changes.insert, "hi");
|
|
assert.deepEqual(c.selection, { anchor: 0, head: 2 });
|
|
});
|
|
|
|
test("formatChange italic and code use the right markers", () => {
|
|
assert.equal(formatChange("x", 0, 1, "italic").changes.insert, "*x*");
|
|
assert.equal(formatChange("x", 0, 1, "code").changes.insert, "`x`");
|
|
});
|
|
|
|
test("formatChange list prefixes each selected line", () => {
|
|
const c = formatChange("a\nb\nc", 0, 5, "list");
|
|
assert.equal(c.changes.insert, "- a\n- b\n- c");
|
|
assert.equal(c.changes.from, 0);
|
|
assert.equal(c.changes.to, 5);
|
|
});
|
|
|
|
test("formatChange list toggles an existing list off", () => {
|
|
const c = formatChange("- a\n- b", 0, 7, "list");
|
|
assert.equal(c.changes.insert, "a\nb");
|
|
});
|
|
|
|
test("formatChange list only affects the lines spanned by the cursor", () => {
|
|
const c = formatChange("head\nbody\ntail", 5, 5, "list");
|
|
assert.equal(c.changes.insert, "- body");
|
|
assert.equal(c.changes.from, 5);
|
|
assert.equal(c.changes.to, 9);
|
|
});
|
|
|
|
test("formatChange link builds [selection](url) and selects the url", () => {
|
|
const c = formatChange("click", 0, 5, "link");
|
|
assert.equal(c.changes.insert, "[click](url)");
|
|
assert.deepEqual(c.selection, { anchor: 8, head: 11 });
|
|
});
|
|
|
|
test("formatChange returns null for an unknown action", () => {
|
|
assert.equal(formatChange("abc", 0, 0, "nope"), null);
|
|
});
|
|
|
|
test("insertText builds an insertion change at the cursor", () => {
|
|
const c = insertText(2, 2, "pasted");
|
|
assert.deepEqual(c.changes, { from: 2, to: 2, insert: "pasted" });
|
|
assert.deepEqual(c.selection, { anchor: 8, head: 8 });
|
|
});
|
|
|
|
// ── ROADMAP #83 — ribbon command transforms ────────────────────────────────
|
|
test("formatChange strike and highlight wrap with the right markers", () => {
|
|
assert.equal(formatChange("x", 0, 1, "strike").changes.insert, "~~x~~");
|
|
assert.equal(formatChange("x", 0, 1, "highlight").changes.insert, "==x==");
|
|
});
|
|
|
|
test("formatChange wikilink and attachment wrap with brackets", () => {
|
|
assert.equal(formatChange("Note", 0, 4, "wikilink").changes.insert, "[[Note]]");
|
|
assert.equal(formatChange("img.png", 0, 7, "attachment").changes.insert, "![[img.png]]");
|
|
});
|
|
|
|
test("formatChange tag prefixes a hash", () => {
|
|
const c = formatChange("devops", 0, 6, "tag");
|
|
assert.equal(c.changes.insert, "#devops");
|
|
assert.deepEqual(c.selection, { anchor: 7, head: 7 });
|
|
});
|
|
|
|
test("formatChange heading replaces an existing heading level", () => {
|
|
const c = formatChange("# Title", 0, 7, "h2");
|
|
assert.equal(c.changes.insert, "## Title");
|
|
});
|
|
|
|
test("formatChange heading toggles off the same level", () => {
|
|
const c = formatChange("## Title", 0, 8, "h2");
|
|
assert.equal(c.changes.insert, "Title");
|
|
});
|
|
|
|
test("formatChange quote, bullet, checkbox and ordered list prefix lines", () => {
|
|
assert.equal(formatChange("a\nb", 0, 3, "quote").changes.insert, "> a\n> b");
|
|
assert.equal(formatChange("a\nb", 0, 3, "bullet").changes.insert, "- a\n- b");
|
|
assert.equal(formatChange("a\nb", 0, 3, "checkbox").changes.insert, "- [ ] a\n- [ ] b");
|
|
assert.equal(formatChange("a\nb", 0, 3, "ordered").changes.insert, "1. a\n2. b");
|
|
});
|
|
|
|
test("formatChange codeblock wraps the selected block in fences", () => {
|
|
const c = formatChange("code", 0, 4, "codeblock");
|
|
assert.equal(c.changes.insert, "```\ncode\n```");
|
|
});
|
|
|
|
test("formatChange indent and dedent shift lines", () => {
|
|
assert.equal(formatChange("a\nb", 0, 3, "indent").changes.insert, " a\n b");
|
|
assert.equal(formatChange(" a\n\tb", 0, 6, "dedent").changes.insert, "a\nb");
|
|
});
|
|
|
|
test("normalizeRibbonCommands filters unknown ids and falls back to default", () => {
|
|
assert.deepEqual(normalizeRibbonCommands(["bold", "nope", "bold"]), ["bold"]);
|
|
assert.deepEqual(normalizeRibbonCommands([]), [...DEFAULT_RIBBON_COMMANDS]);
|
|
assert.deepEqual(normalizeRibbonCommands(null), [...DEFAULT_RIBBON_COMMANDS]);
|
|
});
|
|
|
|
test("moveRibbonCommand reorders within bounds", () => {
|
|
assert.deepEqual(moveRibbonCommand(["a", "b", "c"], "b", -1), ["b", "a", "c"]);
|
|
assert.deepEqual(moveRibbonCommand(["a", "b", "c"], "c", 1), ["a", "b", "c"]);
|
|
assert.deepEqual(moveRibbonCommand(["a", "b"], "a", -1), ["a", "b"]);
|
|
});
|
|
|
|
// ── Textarea application ───────────────────────────────────────────────────
|
|
test("applyFormatToTextarea formats the current textarea selection", () => {
|
|
const ta = w.document.createElement("textarea");
|
|
ta.value = "hello world";
|
|
ta.selectionStart = 0;
|
|
ta.selectionEnd = 5;
|
|
assert.equal(applyFormatToTextarea(ta, "bold"), true);
|
|
assert.equal(ta.value, "**hello** world");
|
|
assert.equal(ta.selectionStart, 2);
|
|
assert.equal(ta.selectionEnd, 7);
|
|
});
|
|
|
|
test("applyFormatToTextarea returns false without an element", () => {
|
|
assert.equal(applyFormatToTextarea(null, "bold"), false);
|
|
});
|
|
|
|
// ── DOM wiring ─────────────────────────────────────────────────────────────
|
|
test("initMobileEditor builds the ribbon toolbar with the gear button", () => {
|
|
initMobileEditor();
|
|
const bar = w.document.getElementById("me-toolbar");
|
|
assert.ok(bar, "toolbar exists");
|
|
assert.ok(bar.classList.contains("me-ribbon"), "uses the ribbon style");
|
|
assert.ok(w.document.querySelector("#me-toolbar .me-ribbon-scroll"), "scroll container exists");
|
|
for (const id of DEFAULT_RIBBON_COMMANDS) {
|
|
assert.ok(w.document.getElementById("me-cmd-" + id), id + " command exists");
|
|
}
|
|
assert.ok(w.document.getElementById("me-ribbon-gear"), "gear button exists");
|
|
assert.ok(w.document.getElementById("me-resize-handle"), "resize handle exists");
|
|
});
|
|
|
|
test("ribbon customiser opens and lists commands", () => {
|
|
const gear = w.document.getElementById("me-ribbon-gear");
|
|
gear.click();
|
|
const sheet = w.document.getElementById("me-ribbon-customizer");
|
|
assert.ok(sheet, "customiser sheet exists");
|
|
assert.ok(sheet.classList.contains("open"), "customiser is open");
|
|
assert.ok(sheet.querySelectorAll(".me-ribbon-cmd-row").length >= RIBBON_COMMANDS.length,
|
|
"every command is listed");
|
|
});
|
|
|
|
test("initMobileEditor is idempotent", () => {
|
|
initMobileEditor();
|
|
const bars = w.document.querySelectorAll("#me-toolbar");
|
|
assert.equal(bars.length, 1);
|
|
});
|
|
|
|
test("applyEditorFontSize persists and clamps the size", () => {
|
|
const applied = applyEditorFontSize(999);
|
|
assert.equal(applied, MAX_EDITOR_FONT);
|
|
assert.equal(localStorage.getItem("obsigate-editor-font-size"), String(MAX_EDITOR_FONT));
|
|
});
|
|
|
|
test("toggleReadingMode toggles the body class and localStorage", () => {
|
|
assert.equal(isReadingMode(), false);
|
|
assert.equal(toggleReadingMode(true), true);
|
|
assert.equal(isReadingMode(), true);
|
|
assert.equal(localStorage.getItem("obsigate-reading-mode"), "1");
|
|
assert.equal(toggleReadingMode(false), false);
|
|
assert.equal(isReadingMode(), false);
|
|
assert.equal(localStorage.getItem("obsigate-reading-mode"), "0");
|
|
});
|
|
|
|
test("reading mode exposes enter and exit controls", () => {
|
|
assert.ok(w.document.getElementById("me-reading-btn"), "enter button exists");
|
|
assert.ok(w.document.getElementById("me-reading-exit"), "exit button exists");
|
|
});
|
|
|
|
test("reading button only shows while a file is open", () => {
|
|
const btn = w.document.getElementById("me-reading-btn");
|
|
state.currentPath = null;
|
|
updateReadingButtonVisibility();
|
|
assert.ok(btn.hidden, "hidden without an open file");
|
|
state.currentPath = "Notes/note.md";
|
|
updateReadingButtonVisibility();
|
|
assert.ok(!btn.hidden, "shown when a file is open");
|
|
state.currentPath = null;
|
|
updateReadingButtonVisibility();
|
|
assert.ok(btn.hidden, "hidden again once the file is closed");
|
|
});
|
|
|
|
test("reading button is hidden while the assistant panel is open", () => {
|
|
const btn = w.document.getElementById("me-reading-btn");
|
|
const panel = w.document.createElement("div");
|
|
panel.className = "bookslm-panel open";
|
|
w.document.body.appendChild(panel);
|
|
state.currentPath = "Notes/note.md";
|
|
updateReadingButtonVisibility();
|
|
assert.ok(btn.hidden, "hidden while the assistant is expanded");
|
|
panel.classList.add("hidden");
|
|
updateReadingButtonVisibility();
|
|
assert.ok(!btn.hidden, "shown again once the assistant is collapsed");
|
|
panel.remove();
|
|
state.currentPath = null;
|
|
updateReadingButtonVisibility();
|
|
});
|
|
|
|
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
|
process.exit(passCount === testCount ? 0 : 1);
|