Files
rustpad/tests/editor_ui_test.mjs
T
2026-08-03 13:13:11 +02:00

160 lines
8.9 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
async function importSource(path) {
const source = await readFile(new URL(path, import.meta.url), "utf8");
return import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
}
class FakeEditor {
constructor(value, start = 0, end = start) {
this.value = value;
this.selectionStart = start;
this.selectionEnd = end;
this.selectionDirection = "none";
this.readOnly = false;
this.inputEvents = 0;
}
setRangeText(replacement, start, end, mode) {
this.value = `${this.value.slice(0, start)}${replacement}${this.value.slice(end)}`;
const caret = mode === "end" ? start + replacement.length : start;
this.selectionStart = caret;
this.selectionEnd = caret;
}
setSelectionRange(start, end, direction = "none") {
this.selectionStart = start;
this.selectionEnd = end;
this.selectionDirection = direction;
}
dispatchEvent(event) {
if (event.type === "input") this.inputEvents += 1;
return true;
}
}
const { applyIndentation } = await importSource("../static/js/editor-format.js");
const markdownSource = await readFile(new URL("../static/js/markdown.js", import.meta.url), "utf8");
const alignSource = markdownSource.match(/export function alignPreviewLineNumbers\(root\) \{[\s\S]*?\n\}/)?.[0];
assert.ok(alignSource, "alignPreviewLineNumbers source must exist");
const alignPreviewLineNumbers = new Function(`${alignSource.replace("export ", "")}; return alignPreviewLineNumbers;`)();
test("Tab inserts two spaces at the caret", () => {
const editor = new FakeEditor("abcd", 2);
assert.equal(applyIndentation(editor), true);
assert.equal(editor.value, "ab cd");
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [4, 4]);
assert.equal(editor.inputEvents, 1);
});
test("Tab indents every selected line", () => {
const editor = new FakeEditor("alpha\nbeta\ngamma", 0, 10);
assert.equal(applyIndentation(editor), true);
assert.equal(editor.value, " alpha\n beta\ngamma");
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [2, 14]);
});
test("Shift+Tab removes up to two spaces from selected lines", () => {
const editor = new FakeEditor(" alpha\n beta", 2, 13);
assert.equal(applyIndentation(editor, { outdent: true }), true);
assert.equal(editor.value, "alpha\nbeta");
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [0, 10]);
});
test("Shift+Tab removes a leading tab", () => {
const editor = new FakeEditor("\talpha", 4);
assert.equal(applyIndentation(editor, { outdent: true }), true);
assert.equal(editor.value, "alpha");
assert.deepEqual([editor.selectionStart, editor.selectionEnd], [3, 3]);
});
test("preview line-number alignment ignores horizontal scroll", () => {
const previousGetComputedStyle = globalThis.getComputedStyle;
globalThis.getComputedStyle = () => ({ paddingLeft: "62px" });
let assigned;
const line = {
getBoundingClientRect: () => ({ left: 70 }),
style: { setProperty: (name, value) => { assigned = [name, value]; } },
};
const root = {
scrollLeft: 80,
getBoundingClientRect: () => ({ left: 100 }),
querySelectorAll: () => [line],
};
try {
alignPreviewLineNumbers(root);
assert.deepEqual(assigned, ["--preview-line-left", "-38px"]);
} finally {
globalThis.getComputedStyle = previousGetComputedStyle;
}
});
test("compact editor layout starts below 1500px", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
assert.equal((css.match(/@media \(max-width: 1499px\)/g) || []).length, 4);
assert.doesNotMatch(css, /@media \(max-width: 1920px\)/);
assert.match(editorSource, /compactLayoutQuery = window\.matchMedia\("\(max-width: 1499px\)"\)/);
assert.match(editorSource, /compactBubbleQuery = matchMedia\("\(max-width: 1499px\)"\)/);
});
test("read-only mode blocks every document write path", async () => {
const editorHtml = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
assert.match(editorHtml, /<textarea id="editor"[\s\S]*?readonly><\/textarea>/);
assert.match(editorSource, /function canEditDocument\(\) \{ return !editor\.readOnly; \}/);
assert.match(editorSource, /function scheduleDocumentSave\(\) \{[\s\S]*?if \(!canEditDocument\(\)\) \{[\s\S]*?saveState\.textContent = "Read only";[\s\S]*?return;/);
assert.match(editorSource, /function activatePreviewEdit\(target, offset = null\) \{\s*if \(!target \|\| !canEditDocument\(\)\) return;/);
assert.match(editorSource, /function commitPreviewEdit\(target,[\s\S]*?if \(!canEditDocument\(\)\) \{ render\(\); return; \}/);
assert.match(editorSource, /if \(socket && canEditDocument\(\)\) socket\.update/g);
assert.match(editorSource, /setDocumentReadOnly\(true, "Read only — changes not saved"\);[\s\S]*?applyRemote\(lastServerContent, lastServerOwnerMap\);[\s\S]*?queueMicrotask\(connect\);/);
});
test("narrow editor switches stay compact", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
assert.match(css, /\.pad-page #mode-toggle \{[\s\S]*?width:\s*auto;[\s\S]*?min-width:\s*0;[\s\S]*?white-space:\s*nowrap;/);
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?\.pad-page \.editor-toolbar \{\s*grid-template-columns:\s*minmax\(0, 1fr\) auto;/);
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?\.pad-page \.view-switch \{[\s\S]*?display:\s*inline-flex;[\s\S]*?width:\s*max-content;[\s\S]*?justify-self:\s*end;/);
assert.doesNotMatch(css, /\.pad-page \.view-switch \{\s*position:\s*static;\s*display:\s*grid;\s*width:\s*100%;/);
});
test("Full HD scaling keeps Preview visible without compacting the whole editor", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page \.toolbar-group \{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?overflow-x:\s*auto;/);
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page \.toolbar-fill \{\s*display:\s*none;/);
assert.match(css, /@media \(min-width: 1500px\) and \(max-width: 1699px\) \{[\s\S]*?\.pad-page :is\(\.editor-controls, \.toolbar-action, \.line-toggle, \.view-switch\) \{\s*flex:\s*0 0 auto;/);
});
test("Markdown/Text uses the same toolbar action styling as More", async () => {
const html = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
assert.match(html, /id="mode-toggle" class="toolbar-action active"/);
assert.doesNotMatch(css, /\.markdown-toggle/);
assert.match(css, /\.markdown-more>summary,\s*\.pad-page \.toolbar-action \{/);
assert.match(editorSource, /modeToggle\.setAttribute\("aria-pressed", String\(markdown\)\)/);
});
test("zoomed editor uses compact labels without hiding any view", async () => {
const html = await readFile(new URL("../static/editor.html", import.meta.url), "utf8");
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
const editorSource = await readFile(new URL("../static/js/note-editor.js", import.meta.url), "utf8");
assert.match(html, /id="mode-toggle"[\s\S]*?control-label-full">Markdown<[\s\S]*?control-label-short" aria-hidden="true">M</);
assert.match(html, /data-view="edit"[\s\S]*?>E<[\s\S]*?data-view="split"[\s\S]*?>S<[\s\S]*?data-view="preview"[\s\S]*?>P</);
assert.match(css, /@media \(max-width: 1699px\) \{[\s\S]*?\.pad-page :is\(#mode-toggle, \.view-switch\) \.control-label-full \{\s*display:\s*none;/);
assert.match(css, /@media \(max-width: 760px\) \{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto auto;[\s\S]*?#mode-toggle \{\s*display:\s*inline-flex;[\s\S]*?button\[data-view="split"\][\s\S]*?display:\s*inline-flex;/);
assert.match(editorSource, /modeToggle\.querySelector\("\.control-label-short"\)\.textContent = markdown \? "M" : "T";/);
});
test("history panel wraps long content without horizontal scrolling", async () => {
const css = await readFile(new URL("../static/css/styles.css", import.meta.url), "utf8");
assert.match(css, /\.history-list \{[\s\S]*?overflow-x:\s*hidden;[\s\S]*?overflow-y:\s*auto;/);
assert.match(css, /\.revision \{[\s\S]*?grid-template-columns:\s*12px minmax\(0, 1fr\);/);
assert.match(css, /\.revision__meta strong,[\s\S]*?\.revision__preview \{[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?word-break:\s*break-word;/);
assert.match(css, /\.revision__preview \{[\s\S]*?overflow-x:\s*hidden;[\s\S]*?white-space:\s*pre-wrap;/);
});