feat(mobile): editeur mobile natif - interface tactile optimisee (#69)
CI / lint (push) Successful in 1m3s
CI / security (push) Successful in 46s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 38s
CI / e2e (push) Successful in 11m12s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m3s
CI / security (push) Successful in 46s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 38s
CI / e2e (push) Successful in 11m12s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Barre d'outils Markdown flottante (gras/italique/code/liste/lien) sur la selection CodeMirror, bouton Coller persistant (contournement iOS), zoom par pincement + hauteur ajustable persistes, raccourcis swipe (gauche -> liens entrants, droite -> table des matieres), mode lecture plein ecran avec navigation entre fichiers du dossier. 22 tests JSDOM + spec E2E mobile. Docs: fiche feature, CHANGELOG, ROADMAP, guide i18n FR/EN, README.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* E2E tests for the native mobile editor UX (ROADMAP #69).
|
||||
*
|
||||
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
|
||||
* skipped on the desktop project that the CI job executes.
|
||||
*
|
||||
* Prerequisites:
|
||||
* cd tests/e2e && npm install && npx playwright install chromium
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test tests/e2e/mobile-editor.spec.js --project=chromium-mobile
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const MOBILE_MAX_WIDTH = 768;
|
||||
|
||||
async function boot(page) {
|
||||
await page.goto('/');
|
||||
// Auth may be disabled (test env) or a session may already exist.
|
||||
await page
|
||||
.waitForSelector('#app:not(.hidden)', { timeout: 15000 })
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('Mobile editor (#69)', () => {
|
||||
test('reading mode button toggles the distraction-free class', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
|
||||
const enter = page.locator('#me-reading-btn');
|
||||
await expect(enter).toBeVisible();
|
||||
await enter.click();
|
||||
|
||||
await expect(page.locator('body')).toHaveClass(/reading-mode/);
|
||||
await expect(page.locator('.header')).toBeHidden();
|
||||
|
||||
await page.locator('#me-reading-exit').click();
|
||||
await expect(page.locator('body')).not.toHaveClass(/reading-mode/);
|
||||
});
|
||||
|
||||
test('editor modal exposes the floating formatting toolbar', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
|
||||
// Force the editor modal open — the toolbar visibility is pure CSS.
|
||||
await page.evaluate(() => document.getElementById('editor-modal').classList.add('active'));
|
||||
|
||||
const toolbar = page.locator('#me-toolbar');
|
||||
await expect(toolbar).toBeVisible();
|
||||
for (const action of ['bold', 'italic', 'code', 'list', 'link']) {
|
||||
await expect(page.locator('#me-fmt-' + action)).toBeVisible();
|
||||
}
|
||||
await expect(page.locator('#me-paste')).toBeVisible();
|
||||
});
|
||||
|
||||
test('font zoom buttons persist the editor font size', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
await page.evaluate(() => document.getElementById('editor-modal').classList.add('active'));
|
||||
|
||||
await page.locator('#me-font-plus').click();
|
||||
await page.locator('#me-font-plus').click();
|
||||
const stored = await page.evaluate(() => localStorage.getItem('obsigate-editor-font-size'));
|
||||
expect(Number(stored)).toBeGreaterThan(13.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/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";
|
||||
|
||||
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,
|
||||
MIN_EDITOR_FONT,
|
||||
MAX_EDITOR_FONT,
|
||||
clampZoom,
|
||||
detectSwipe,
|
||||
formatChange,
|
||||
insertText,
|
||||
applyFormatToTextarea,
|
||||
applyEditorFontSize,
|
||||
initMobileEditor,
|
||||
toggleReadingMode,
|
||||
isReadingMode,
|
||||
} = 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, "strike"), 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 });
|
||||
});
|
||||
|
||||
// ── 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 floating toolbar with the five actions", () => {
|
||||
initMobileEditor();
|
||||
const bar = w.document.getElementById("me-toolbar");
|
||||
assert.ok(bar, "toolbar exists");
|
||||
for (const action of FORMAT_ACTIONS) {
|
||||
assert.ok(w.document.getElementById("me-fmt-" + action), action + " button exists");
|
||||
}
|
||||
assert.ok(w.document.getElementById("me-paste"), "paste button exists");
|
||||
assert.ok(w.document.getElementById("me-resize-handle"), "resize handle exists");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
Reference in New Issue
Block a user