Files
ObsiGate/tests/frontend/mobile-editor.test.mjs
T
bruno 520059d866
CI / lint (push) Successful in 1m13s
CI / security (push) Successful in 47s
CI / test (push) Successful in 2m41s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 11m14s
feat(mobile): ruban d'edition style Obsidian + correctifs mobile (#83, BUG-017 a BUG-020)
- #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.
2026-09-13 11:49:45 -04:00

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);