Files
KantSites/tests/editor-model.test.mjs
T
kantarciandClaude Opus 5 70283db20a
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Dokumentschema v17: Projektraster, verlinktes Bild, klebender Kopfbereich
Alles aus dem Nachbau von kant-it.de, den Umut durchgesehen hat.

Eine Beitragsliste kann Datum, Autor, Auszug und Kategorie ausblenden und
sieht damit aus wie ein Projektraster statt wie ein Blogarchiv. Beinahe
lautlos danebengegangen: |default(true) macht in Twig aus einem
gespeicherten false ein true - die Einstellung haette sich speichern
lassen und nichts getan. Der Test prueft beide Richtungen.

Ein Bild darf verlinken, damit ein Logo auf die Startseite zeigt; die
Adresse geht durch dieselbe Pruefung wie jeder andere Link. Und der
Kopfbereich kann beim Scrollen oben bleiben - als Einstellung der
Website, weil nur das umschliessende Element kleben kann.

Der groesste Fund steckt im Editor: die Liste bekannter Schemafassungen
endete bei 12, waehrend Bausteine v13 bis v15 verlangen. In einem
aktuellen Dokument liess sich damit kein Baustein mehr einfuegen, und
Symbol, Fortschritt, Slider und externer Inhalt in gar keinem. Jetzt eine
Zahl an einer Stelle, mit einem Test ueber jeden Typ und jede Fassung.

Geprueft: 389 PHP-Tests unter 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:02:23 +02:00

360 lines
21 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
import { readFile } from 'node:fs/promises';
import { designCss } from '../scripts/build-design.mjs';
function fixture() { return { schemaVersion: 1, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }, { id: 't1', type: 'text', props: { text: 'Body' } }] }, { id: 's2', type: 'section', children: [] }] }; }
test('typing coalesces into one undo and redo; saved state tracks content, not stack length', () => {
const history = new EditorHistory(fixture());
history.change(doc => doc.title = 'New', 'title');
history.change(doc => doc.title = 'New title', 'title');
assert.equal(history.past.length, 1);
history.undo(); assert.equal(history.document.title, 'Page'); assert.equal(history.dirty, false);
history.redo(); assert.equal(history.document.title, 'New title'); assert.equal(history.dirty, true);
history.markSaved(history.document); assert.equal(history.dirty, false);
history.undo(); assert.equal(history.dirty, true);
history.redo(); assert.equal(history.dirty, false);
});
test('new edit after undo drops redo and source snapshots remain immutable', () => {
const original = fixture(); const history = new EditorHistory(original);
history.change(doc => locate(doc.blocks, 't1').block.props.text = 'changed');
history.undo(); history.change(doc => doc.title = 'Branch');
assert.equal(history.future.length, 0); assert.equal(original.title, 'Page');
assert.equal(locate(original.blocks, 't1').block.props.text, 'Body');
});
test('add into selected container, add after leaf, and duplicate assigns fresh IDs recursively', () => {
const document = fixture();
const id = insertBlock(document, 'text', 's2'); assert.equal(locate(document.blocks, id).parent.id, 's2');
const after = insertBlock(document, 'button', 't1'); assert.equal(document.blocks[0].children[2].id, after);
const copy = duplicateBlock(document, 's1');
const copied = locate(document.blocks, copy).block;
assert.notEqual(copied.children[0].id, 'h1'); assert.equal(copied.children[0].props.level, 2);
assert.equal(document.blocks[0].children[0].props.level, 1);
});
test('moving prevents self/descendant cycles and supports cross-container moves', () => {
const document = fixture();
assert.equal(moveBlock(document, 's1', 'h1', true), false);
assert.equal(moveBlock(document, 's1', 's1', true), false);
assert.equal(moveBlock(document, 'h1', 't1', true), false);
assert.equal(moveBlock(document, 't1', 's2', true), true);
assert.equal(locate(document.blocks, 't1').parent.id, 's2');
assert.equal(moveBlock(document, 's2', 's1'), true);
assert.equal(document.blocks[0].id, 's2');
});
test('history persists exact block document after add, move, delete, undo and JSON reload', () => {
const history = new EditorHistory(fixture());
const id = history.change(doc => insertBlock(doc, 'image', 's2'));
history.change(doc => moveBlock(doc, id, 't1'));
const saved = structuredClone(history.document);
history.change(doc => { const target = locate(doc.blocks, id); target.siblings.splice(target.index, 1); });
history.undo(); assert.deepEqual(history.document, saved);
const loaded = new EditorHistory(JSON.parse(JSON.stringify(history.document)));
assert.deepEqual(loaded.document, saved); assert.equal(loaded.dirty, false);
assert.throws(() => createBlock('unknown'));
});
test('v1 to v2 migration preserves all content and IDs, is idempotent and rejects future versions', () => {
const original = fixture(); const migrated = upgradeDocument(structuredClone(original));
assert.deepEqual(migrated, { ...original, schemaVersion: 2 });
assert.deepEqual(upgradeDocument(structuredClone(migrated)), migrated);
assert.throws(() => upgradeDocument({ ...original, schemaVersion: 999 }));
assert.equal(original.schemaVersion, 1);
});
test('responsive inheritance, reset, migration undo, and reload preserve distinct device values', () => {
const history = new EditorHistory(fixture());
history.change(doc => setDesign(doc, 'h1', 'desktop', 'fontSize', 'hero'));
assert.equal(history.document.schemaVersion, 2);
const heading = () => locate(history.document.blocks, 'h1').block;
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'hero', source: 'desktop' });
history.undo(); assert.deepEqual(history.document, fixture()); history.redo();
history.change(doc => setDesign(doc, 'h1', 'tablet', 'fontSize', 'display'));
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', 'title'));
assert.equal(effectiveDesign(heading(), 'desktop', 'fontSize').value, 'hero');
assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', ''));
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'display', source: 'tablet' });
history.undo(); assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
assert.deepEqual(JSON.parse(JSON.stringify(history.document)), history.document);
});
test('presets insert after containing section, keep a single H1, unique IDs, and undo atomically', () => {
const history = new EditorHistory(fixture());
for (const name of ['intro', 'columns', 'cta']) {
const id = history.change(doc => insertPreset(doc, name, 'h1'));
assert.equal(history.document.blocks[1].id, id);
assert.deepEqual(blockPath(history.document.blocks, 'h1').map(block => block.id), ['s1', 'h1']);
}
const nodes = [];
const visit = blocks => blocks.forEach(block => { nodes.push(block); visit(block.children || []); });
visit(history.document.blocks);
assert.equal(new Set(nodes.map(block => block.id)).size, nodes.length);
assert.equal(nodes.filter(block => block.type === 'heading' && block.props.level === 1).length, 1);
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
});
test('committed CSS matches the shared validated token manifest', async () => {
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
assert.equal(await readFile(new URL('../public/assets/design.css', import.meta.url), 'utf8'), designCss(tokens));
});
test('v3 media and effects keep originals, gallery restrictions, and atomic undo', () => {
const history = new EditorHistory(fixture());
const media = { id: 'a'.repeat(32), alt: 'Landscape', variants: [{src: '/media/a/480.webp', width: 480, height: 320}] };
const gallery = history.change(doc => insertMedia(doc, 's2', [media, media]));
assert.equal(history.document.schemaVersion, 3);
const children = locate(history.document.blocks, gallery).block.children;
assert.equal(children.length, 2); assert.notEqual(children[0].id, children[1].id);
assert.equal(moveBlock(history.document, 'h1', gallery, true), false);
assert.equal(moveBlock(history.document, 'h1', children[0].id), false);
const text = history.change(doc => insertBlock(doc, 'text', children[0].id));
assert.notEqual(locate(history.document.blocks, text).parent?.id, gallery);
history.undo();
history.change(doc => setEffect(doc, gallery, 'shadow', 'soft'));
history.change(doc => setDesign(doc, gallery, 'mobile', 'columns', 'one'));
assert.equal(history.document.schemaVersion, 3);
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
});
test('plugin blocks stay movable and intact while the editor refuses to fill or edit them', () => {
const plugin = { id: 'p1', type: 'acme.slider', props: { slides: [{ headline: 'From the plugin' }] }, children: [{ id: 'pc1', type: 'text', props: { text: 'Nested' } }] };
const start = { schemaVersion: 4, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }] }, structuredClone(plugin)] };
const history = new EditorHistory(structuredClone(start));
assert.equal(isPluginBlock(plugin), true);
assert.equal(isPluginBlock({ type: 'section' }), false);
for (const type of ['Acme.Slider', 'acme.', '.slider', 'acme/slider', 'section']) assert.equal(isPluginBlock({ type }), false, type);
assert.equal(blockLabel(plugin), 'Baustein acme.slider');
assert.equal(blockIcon(plugin), '❔');
assert.equal(blockLabel({ type: 'section' }), 'Sektion');
// Nothing may be dropped into a block whose layout contract the core does not know.
assert.equal(canContain(plugin, { type: 'text' }), false);
assert.equal(isContainer(plugin), false);
assert.equal(moveBlock(history.document, 'h1', 'p1', true), false);
assert.throws(() => createBlock('acme.slider'), /Unbekannter Block/);
// Moving and duplicating preserve the stored block including its unknown props and children.
assert.equal(history.change(doc => moveBlock(doc, 'p1', 's1', true)), true);
assert.equal(locate(history.document.blocks, 'p1').parent.id, 's1');
const copy = history.change(doc => duplicateBlock(doc, 'p1'));
const duplicate = locate(history.document.blocks, copy).block;
assert.notEqual(duplicate.id, 'p1');
assert.notEqual(duplicate.children[0].id, 'pc1');
assert.deepEqual(duplicate.props, plugin.props);
assert.deepEqual(locate(history.document.blocks, 'p1').block.props, plugin.props);
// A full undo must restore the plugin block byte for byte.
history.undo(); history.undo();
assert.deepEqual(history.document, start);
assert.equal(upgradeDocument(history.document, 4).schemaVersion, 4);
assert.deepEqual(JSON.parse(JSON.stringify(history.document)).blocks.find(b => b.id === 'p1'), plugin);
});
test('the post list block carries only its filter, lifts the schema to v5 and undoes atomically', () => {
const history = new EditorHistory(fixture());
const id = history.change(doc => insertBlock(doc, 'postlist', 's2'));
const block = locate(history.document.blocks, id).block;
// Content is the filter and the count; the posts themselves are added when the page is served.
assert.deepEqual(block.props, { count: 3, category: '', tag: '' });
assert.equal(block.children, undefined);
assert.equal(history.document.schemaVersion, 5);
// Nothing can be nested inside it, and it does not swallow a following block.
assert.equal(canContain(block, { type: 'text' }), false);
const text = history.change(doc => insertBlock(doc, 'text', id));
assert.notEqual(locate(history.document.blocks, text).parent?.id, id);
history.undo(); history.undo();
assert.deepEqual(history.document, fixture());
assert.equal(history.document.schemaVersion, 1);
});
test('v6 containers accept only their own children and an accordion starts with one entry', () => {
const history = new EditorHistory(fixture());
const accordion = history.change(doc => insertBlock(doc, 'accordion', 's2'));
const block = locate(history.document.blocks, accordion).block;
assert.equal(history.document.schemaVersion, 6);
// An empty accordion would render as nothing, so it comes with one entry.
assert.equal(block.children.length, 1);
assert.equal(block.children[0].type, 'accordionitem');
assert.deepEqual(block.children[0].props, { title: 'Eine Frage', open: false });
// Only accordion entries go into an accordion, and an entry goes nowhere else.
assert.equal(canContain(block, { type: 'text' }), false);
assert.equal(canContain(block, { type: 'accordionitem' }), true);
assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false);
assert.equal(canContain(block.children[0], { type: 'text' }), true);
// A logo strip takes images only, like a gallery.
const logos = history.change(doc => insertBlock(doc, 'logos', 's2'));
const strip = locate(history.document.blocks, logos).block;
assert.equal(canContain(strip, { type: 'image' }), true);
assert.equal(canContain(strip, { type: 'iconcard' }), false);
// Leaf blocks stay leaves and keep their defaults.
// Two statements on purpose: history.change() replaces the document, so reading
// history.document.blocks in the same expression would search the previous snapshot.
const statId = history.change(doc => insertBlock(doc, 'stat', 's2'));
const stat = locate(history.document.blocks, statId).block;
assert.equal(stat.children, undefined);
assert.deepEqual(stat.props, { value: '15+', label: 'Jahre Erfahrung' });
history.undo(); history.undo(); history.undo();
assert.deepEqual(history.document, fixture());
});
test('a new tab group starts with two tabs and accepts nothing but tabs', () => {
const history = new EditorHistory(fixture());
const id = history.change(doc => insertBlock(doc, 'tabs', 's2'));
const group = locate(history.document.blocks, id).block;
assert.equal(history.document.schemaVersion, 7);
// One tab is not a tab group, and the enhancement needs at least two to do anything.
assert.equal(group.children.length, 2);
assert.deepEqual(group.children.map(c => c.type), ['tabitem', 'tabitem']);
assert.notEqual(group.children[0].id, group.children[1].id);
assert.deepEqual(group.children[0].props, { title: 'Neuer Tab' });
assert.equal(canContain(group, { type: 'tabitem' }), true);
assert.equal(canContain(group, { type: 'text' }), false);
assert.equal(canContain({ type: 'section', children: [] }, { type: 'tabitem' }), false);
assert.equal(canContain({ type: 'accordion', children: [] }, { type: 'tabitem' }), false);
// A tab itself holds ordinary content.
assert.equal(canContain(group.children[0], { type: 'text' }), true);
history.undo();
assert.deepEqual(history.document, fixture());
// On a page without a main heading the hero brings one.
const empty = new EditorHistory({ schemaVersion: 1, title: 'Leer', description: '', blocks: [] });
const first = empty.change(doc => insertPreset(doc, 'hero', null));
assert.equal(locate(empty.document.blocks, first).block.children[0].props.level, 1);
});
test('free styles inherit from the wider device, reset individually and leave no skeleton', () => {
const history = new EditorHistory(fixture());
history.change(doc => setStyle(doc, 's1', 'desktop', 'fontSize', '42px'));
history.change(doc => setStyle(doc, 's1', 'desktop', 'padding', '80px', { side: 'top' }));
history.change(doc => setStyle(doc, 's1', 'desktop', 'color', '#ffffff', { hover: true }));
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', '28px'));
const block = () => locate(history.document.blocks, 's1').block;
assert.equal(history.document.schemaVersion, 10);
// Tablet has no own value, so it inherits the desktop one; mobile keeps its own.
assert.deepEqual(effectiveStyle(block(), 'tablet', 'fontSize'), { value: '42px', source: 'desktop' });
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '28px', source: 'mobile' });
assert.deepEqual(effectiveStyle(block(), 'desktop', 'padding', 'top'), { value: '80px', source: 'desktop' });
assert.equal(hoverStyle(block(), 'desktop', 'color'), '#ffffff');
// Clearing one value makes it inherit again and removes the now empty container.
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', ''));
assert.equal(block().style.mobile, undefined);
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '42px', source: 'desktop' });
// Copying carries the styling over without sharing objects.
history.change(doc => copyStyle(doc, 's1', 's2'));
const copy = locate(history.document.blocks, 's2').block;
assert.deepEqual(copy.style, block().style);
assert.notEqual(copy.style, block().style);
history.change(doc => clearStyle(doc, 's1'));
assert.equal(block().style, undefined);
// Every step is one history entry, so undoing all of them restores the original document.
for (let i = 0; i < 7; i++) history.undo();
assert.deepEqual(history.document, fixture());
});
test('a row arrives with its columns, inside the selected container', () => {
const history = new EditorHistory(fixture());
// With a section selected, the row becomes a child of it - that is where people expect it.
const rowId = history.change(doc => insertRow(doc, 3, 's1'));
const row = locate(history.document.blocks, rowId);
assert.equal(row.block.type, 'row');
assert.equal(row.parent.id, 's1');
assert.equal(row.block.children.length, 3);
assert.ok(row.block.children.every(child => child.type === 'column'));
assert.equal(row.block.layout.design.desktop.columns, 'three');
assert.equal(row.block.layout.design.mobile.columns, 'one');
// Column counts live in the design tokens, which need schema v2.
assert.ok(history.document.schemaVersion >= 2);
// With a heading selected, the row is placed after it rather than inside it.
const second = history.change(doc => insertRow(doc, 2, 'h1'));
const placed = locate(history.document.blocks, second);
assert.equal(placed.parent.id, 's1');
assert.equal(placed.siblings[placed.index - 1].id, 'h1');
assert.equal(placed.block.children.length, 2);
// Out of range counts are clamped instead of producing nonsense. Two statements, because
// history.change replaces the document: reading blocks in the same expression reads the old one.
const wideId = history.change(doc => insertRow(doc, 99, 's2'));
assert.equal(locate(history.document.blocks, wideId).block.children.length, 6);
const narrowId = history.change(doc => insertRow(doc, 0, 's2'));
assert.equal(locate(history.document.blocks, narrowId).block.children.length, 2, 'An unusable number falls back to two.');
// Each insertion is one history entry.
for (let i = 0; i < 4; i++) history.undo();
assert.deepEqual(history.document, fixture());
});
test('the hero preset assembles a full screen section that centres its content', () => {
const history = new EditorHistory(fixture());
const id = history.change(doc => insertPreset(doc, 'hero', 's1'));
const hero = locate(history.document.blocks, id).block;
assert.equal(hero.type, 'section');
assert.equal(hero.layout.width, 'full', 'Edge to edge, or it is not full screen.');
// dvh, not vh: on a phone 100vh is taller than the screen.
assert.equal(hero.style.desktop.minHeight, '100dvh');
assert.equal(hero.style.mobile.minHeight, '80dvh');
assert.equal(hero.style.desktop.display, 'flex');
assert.equal(hero.style.desktop.alignItems, 'center');
assert.equal(hero.style.desktop.backgroundSize, 'cover');
assert.ok(hero.style.desktop.overlayOpacity > 0, 'An overlay, so text stays readable on a photo.');
assert.deepEqual(hero.children.map(child => child.type), ['heading', 'text', 'button']);
// The fixture already has a main heading, so the hero takes a second level one: a page has
// exactly one h1, and a document with two would be refused on save.
assert.equal(hero.children[0].props.level, 2);
// Free styles need schema v10.
assert.ok(history.document.schemaVersion >= 10);
history.undo();
assert.deepEqual(history.document, fixture());
});
test('a current document still accepts new blocks, and every block type can be inserted', () => {
// Der Fehler, den der Nachbau von kant-it.de gefunden hat: die Liste bekannter Schemafassungen
// im Editor endete bei 12, während Bausteine schon v13 bis v15 verlangten. In einem aktuellen
// Dokument ließ sich damit gar nichts mehr einfügen - und ein Symbol, ein Slider oder ein
// externer Inhalt nirgends.
for (const version of [1, 5, 12, 13, 15, 16, SCHEMA_VERSION]) {
const history = new EditorHistory({ ...fixture(), schemaVersion: version });
const id = history.change(doc => insertBlock(doc, 'iconcard', 's2'));
assert.ok(id, `v${version} nimmt einen Baustein an`);
assert.ok(history.document.schemaVersion >= Math.max(version, 6));
}
// Jeder Typ, der eine Fassung anhebt, landet tatsächlich im Dokument.
for (const [type, needed] of [['gallery', 3], ['postlist', 5], ['quote', 6], ['tabs', 7], ['form', 8], ['breadcrumb', 9], ['menu', 11], ['embed', 14], ['icon', 15], ['progress', 15], ['slider', 15]]) {
const history = new EditorHistory(fixture());
const id = history.change(doc => insertBlock(doc, type, 's2'));
assert.ok(locate(history.document.blocks, id), `${type} wird eingefügt`);
assert.ok(history.document.schemaVersion >= needed, `${type} hebt auf mindestens v${needed}`);
}
// Eine erfundene Fassung bleibt abgewiesen.
assert.throws(() => upgradeDocument({ schemaVersion: 99, blocks: [] }, 6), /Unbekanntes Dokumentschema/);
assert.throws(() => upgradeDocument({ schemaVersion: 5, blocks: [] }, SCHEMA_VERSION + 1), /Unbekanntes Dokumentschema/);
});