Files
KantSites/tests/editor-model.test.mjs
T
kantarci a3029966cb
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
fix: expose accordion contents and add clear insert actions (1.8.3)
2026-09-14 12:53:16 +02:00

593 lines
34 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, 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';
import { relevantProperty } from '../public/assets/editor-model.js';
test('sections start after the containing root, explicit placement can nest, undo restores the tree', () => {
const original = fixture();
const history = new EditorHistory(original);
const id = history.change(doc => insertBlock(doc, 'section', 't1'));
assert.equal(history.document.blocks[1].id, id);
assert.equal(locate(history.document.blocks, id).parent, null);
history.undo(); assert.deepEqual(history.document, original);
history.redo(); assert.equal(history.document.blocks[1].id, id);
const nested = history.change(doc => insertBlockAt(doc, 'section', 's1', 0));
assert.equal(locate(history.document.blocks, nested).parent.id, 's1');
});
test('design fields follow block type without hiding existing stored values', () => {
const text = createBlock('text'), image = createBlock('image');
assert.equal(relevantProperty(text, 'objectFit', { group: 'image' }), false);
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), false);
assert.equal(relevantProperty(image, 'objectFit', { group: 'image' }), true);
assert.equal(relevantProperty(text, 'fontSize', { group: 'text' }), true);
assert.equal(relevantProperty(text, 'gridColumns', { group: 'layout' }), false);
image.style = { mobile: { fontSize: '14px' } };
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), true);
});
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 }));
// Der Editor muss dieselbe Fassung kennen wie der Validator - sonst laesst sich in einem
// aktuellen Dokument nichts mehr einfuegen, ohne dass irgendwo eine Fehlermeldung erscheint.
// Genau das ist hier schon zweimal passiert, zuletzt bei v18.
const validator = readFileSync(new URL('../src/Content/DocumentValidator.php', import.meta.url), 'utf8');
const hoechste = Math.max(...[...validator.matchAll(/schemaVersion >= (\d+)/g)].map(treffer => Number(treffer[1])));
assert.equal(SCHEMA_VERSION, hoechste, `Der Editor kennt v${SCHEMA_VERSION}, der Validator kennt v${hoechste}.`);
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: '', layout: 'karten' });
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);
// Selecting the group must not silently put a text outside it. Choose the entry first.
assert.equal(insertionTarget(history.document, accordion, 'text'), null);
assert.equal(insertBlock(history.document, 'text', accordion), null);
const entry = block.children[0].id;
const text = history.change(doc => insertBlock(doc, 'text', entry));
assert.equal(locate(history.document.blocks, text).parent.id, entry);
history.undo();
assert.equal(locate(history.document.blocks, entry).block.children.length, 0);
history.redo();
assert.equal(locate(history.document.blocks, text).parent.id, entry);
history.undo();
// 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('removing an inherited background upgrades the schema and can be reset or undone', () => {
const history = new EditorHistory(fixture());
history.change(doc => setStyle(doc, 's1', 'desktop', 'backgroundImage', 'a'.repeat(32)));
assert.equal(history.document.schemaVersion, 10);
history.change(doc => setStyle(doc, 's1', 'tablet', 'backgroundImage', 'none'));
const block = () => locate(history.document.blocks, 's1').block;
assert.equal(history.document.schemaVersion, 20);
assert.deepEqual(effectiveStyle(block(), 'mobile', 'backgroundImage'), { value: 'none', source: 'tablet' });
history.change(doc => setStyle(doc, 's1', 'tablet', 'backgroundImage', ''));
assert.deepEqual(effectiveStyle(block(), 'mobile', 'backgroundImage'), { value: 'a'.repeat(32), source: 'desktop' });
history.undo();
assert.equal(block().style.tablet.backgroundImage, 'none');
history.undo();
assert.equal(history.document.schemaVersion, 10);
});
test('replacing an image preserves its link, caption and layout and remains one undo step', () => {
const history = new EditorHistory(fixture());
const first = { id: 'a'.repeat(32), alt: 'First', variants: [{ src: '/media/a/192.webp', width: 192, height: 96 }] };
const second = { id: 'b'.repeat(32), alt: 'Second', variants: [{ src: '/media/b/480.webp', width: 480, height: 320 }] };
const galleryId = history.change(doc => insertMedia(doc, 's1', [first]));
const id = locate(history.document.blocks, galleryId).block.children[0].id;
history.change(doc => {
const block = locate(doc.blocks, id).block;
Object.assign(block.props, { href: '/kontakt', click: 'link', newTab: true, caption: 'Meine Bildunterschrift' });
setStyle(doc, id, 'desktop', 'width', '50%'); upgradeDocument(doc, 20);
});
const before = structuredClone(history.document);
history.change(doc => insertMedia(doc, id, [second]));
const replaced = locate(history.document.blocks, id).block;
assert.equal(replaced.props.mediaId, second.id);
assert.equal(replaced.props.width, 480); assert.equal(replaced.props.height, 320);
assert.equal(replaced.props.alt, 'Second'); assert.equal(replaced.props.href, '/kontakt');
assert.equal(replaced.props.click, 'link'); assert.equal(replaced.props.newTab, true);
assert.equal(replaced.props.caption, 'Meine Bildunterschrift');
assert.equal(replaced.style.desktop.width, '50%');
history.undo(); assert.deepEqual(history.document, before);
});
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/);
});
// --- Punktgenaues Einfuegen und Verschieben -------------------------------------------------
// Die Grundlage fuer das Ablegen in der Vorschau: dort zaehlt die Stelle, nicht die Auswahl.
test('a drop lands at the exact position, also at the very end of a list', () => {
const document = fixture();
const section = locate(document.blocks, 's1').block;
insertBlockAt(document, 'text', 's1', 0);
assert.deepEqual(section.children.map(block => block.type), ['text', 'heading', 'text']);
insertBlockAt(document, 'button', 's1', section.children.length);
assert.equal(section.children.at(-1).type, 'button');
// Ein Index jenseits des Endes haengt an, statt zu scheitern.
assert.ok(insertBlockAt(document, 'button', 's1', 99));
assert.equal(section.children.length, 5);
});
test('moving to an exact position settles the shift before it clamps', () => {
const order = document => document.blocks[0].children.map(block => block.id);
const document = fixture();
insertBlockAt(document, 'button', 's1', 2);
const ids = order(document);
// Nach hinten: die Entnahme verschiebt jede spaetere Stelle um eins.
assert.equal(moveBlockTo(document, ids[0], 's1', 2), true);
assert.deepEqual(order(document), [ids[1], ids[0], ids[2]]);
// Ganz ans Ende. Ohne die richtige Reihenfolge von Verrechnen und Begrenzen landete der
// Block hier eine Stelle zu frueh.
assert.equal(moveBlockTo(document, ids[1], 's1', 3), true);
assert.deepEqual(order(document), [ids[0], ids[2], ids[1]]);
// Nach vorne: dort wird nichts verrechnet.
assert.equal(moveBlockTo(document, ids[1], 's1', 0), true);
assert.deepEqual(order(document), [ids[1], ids[0], ids[2]]);
});
test('a drop never detaches a branch and never breaks a container rule', () => {
const document = fixture();
upgradeDocument(document, SCHEMA_VERSION);
// Eine Sektion in ihr eigenes Kind: das wuerde den Ast abtrennen.
assert.equal(moveBlockTo(document, 's1', 'h1', 0), false);
assert.equal(moveBlockTo(document, 's1', 's1', 0), false);
assert.equal(document.blocks.length, 2);
// Eine Galerie nimmt nur Bilder.
const galleryId = insertBlock(document, 'gallery', null);
assert.equal(moveBlockTo(document, 'h1', galleryId, 0), false);
assert.equal(locate(document.blocks, 'h1').parent.id, 's1');
assert.equal(insertBlockAt(document, 'text', galleryId, 0), null);
assert.ok(insertBlockAt(document, 'image', galleryId, 0));
});
test('the top level takes everything the validator takes there, and nothing else', () => {
const document = fixture();
upgradeDocument(document, SCHEMA_VERSION);
// Ein Akkordeon-Eintrag und ein Tab gehoeren in ihre Gruppe - der Validator lehnt sie sonst ab.
assert.equal(dropList(document, null, { type: 'accordionitem' }), null);
assert.equal(dropList(document, null, { type: 'tabitem' }), null);
// Alles andere darf dort stehen, genau wie ueber die Schaltflaechen.
for (const type of ['section', 'heading', 'image', 'text']) {
assert.equal(dropList(document, null, { type }), document.blocks, type);
}
// Beim Ziehen aus dem Werkzeugkasten gibt es den Block noch nicht.
assert.doesNotThrow(() => dropList(document, 's1', null));
});
// --- Sektion, Zeile, Spalte, Inhalt ----------------------------------------------------------
// Die Struktur, die der Editor in seinem eigenen Hinweistext beschreibt, gilt jetzt auch fuer
// die Schaltflaechen: eine Zeile nahm bisher jedes Kind, eine Spalte stand ueberall.
test('a row takes columns and nothing else, a column only lives in a row', () => {
const document = fixture();
const rowId = insertRow(document, 3, 's2');
const row = locate(document.blocks, rowId).block;
assert.equal(row.children.length, 3);
assert.ok(row.children.every(child => child.type === 'column'));
// Eine Ueberschrift gehoert nicht neben die Spalten, sondern in eine davon.
assert.equal(insertBlockAt(document, 'heading', rowId, 0), null);
assert.equal(row.children.length, 3);
assert.ok(insertBlockAt(document, 'heading', row.children[0].id, 0));
// Eine Spalte gehoert nicht in eine Sektion.
assert.equal(insertBlockAt(document, 'column', 's2', 0), null);
assert.ok(insertBlockAt(document, 'column', rowId, 3));
});
test('nothing that needs a container is offered at the top of a document', () => {
const document = fixture();
for (const type of ['row', 'column', 'accordionitem', 'tabitem']) {
assert.equal(insertionTarget(document, null, type), null, type);
assert.equal(insertBlock(document, type, null), null, type);
}
// Eine Sektion dagegen schon - damit faengt eine Seite an.
assert.ok(insertBlock(document, 'section', null));
});
test('the toolbox is told the same place the click will use', () => {
const document = fixture();
upgradeDocument(document, SCHEMA_VERSION);
const rowId = insertRow(document, 2, 's2');
const columnId = locate(document.blocks, rowId).block.children[0].id;
// In einer Zeile ist nur eine Spalte moeglich, und sie landet in der Zeile.
assert.deepEqual(insertionTarget(document, rowId, 'column'), { parentId: rowId, index: 2 });
// Eine Ueberschrift nimmt die Zeile nicht - sie steigt auf und landet hinter der Zeile in der
// Sektion. Sperren waere unnoetig: die Absicht ist eindeutig, nur die Zeile ist der falsche Ort.
assert.deepEqual(insertionTarget(document, rowId, 'heading'), { parentId: 's2', index: 1 });
// In einer Spalte ist Inhalt moeglich, und er landet in der Spalte.
assert.equal(insertionTarget(document, columnId, 'heading').parentId, columnId);
// Eine weitere Spalte neben der gewaehlten: eine Ebene hoeher, direkt dahinter.
assert.deepEqual(insertionTarget(document, columnId, 'column'), { parentId: rowId, index: 1 });
// Und die Auskunft stimmt mit der Tat ueberein.
for (const [selection, type] of [[rowId, 'column'], [columnId, 'heading'], [columnId, 'column'], ['s2', 'row']]) {
const promised = insertionTarget(document, selection, type);
const id = insertBlock(document, type, selection);
assert.equal(locate(document.blocks, id).parent?.id ?? null, promised.parentId, `${type} aus ${selection}`);
}
});
test('every block type appears in exactly one toolbox group', () => {
const listed = CATEGORIES.flatMap(category => category.types);
assert.equal(listed.length, new Set(listed).size, 'kein Typ doppelt');
assert.deepEqual([...listed].sort(), Object.keys(blockLabels).sort());
});
test('a row dropped at an exact position brings its columns along', () => {
const document = fixture();
assert.ok(insertRowAt(document, 4, 's1', 0));
const row = locate(document.blocks, 's1').block.children[0];
assert.equal(row.type, 'row');
assert.equal(row.children.length, 4);
// Und in eine Zeile hinein gibt es keine zweite Zeile.
assert.equal(insertRowAt(document, 2, row.id, 0), null);
});
// --- Fassungskennung an den Modulen ---------------------------------------------------------
test('every module imported by another one is listed in the import map', () => {
// `public/.htaccess` laesst jede .js-Datei ein Jahr lang als `immutable` zwischenspeichern.
// Die Einstiegsdatei bekommt ihr `?v=` aus der Vorlage, ein `import './editor-model.js'` darin
// nicht - der Browser behielte das alte Modul und der Editor braeche mit "does not provide an
// export named ..." ab. Genau das ist nach 1.5.0 passiert. Die Importkarte haengt die Fassung
// an; ein neues Modul, das dort fehlt, faellt hier auf und nicht erst beim naechsten Update.
const quellen = readdirSync('public/assets').filter(name => name.endsWith('.js'));
const importiert = new Set();
for (const name of quellen) {
for (const treffer of readFileSync(`public/assets/${name}`, 'utf8').matchAll(/from '\.\/([\w-]+\.js)'/g)) {
importiert.add(treffer[1]);
}
}
assert.ok(importiert.size > 0, 'es gibt ueberhaupt Modulimporte');
const vorlage = readFileSync('templates/admin/base.html.twig', 'utf8');
const karte = /<script type="importmap"[\s\S]*?<\/script>/.exec(vorlage);
assert.ok(karte, 'base.html.twig traegt eine Importkarte');
const eingetragen = new Set([...karte[0].matchAll(/'([\w-]+)'/g)].map(treffer => `${treffer[1]}.js`));
const fehlend = [...importiert].filter(name => !eingetragen.has(name)).sort();
assert.deepEqual(fehlend, [], `ohne Fassungskennung in der Importkarte: ${fehlend.join(', ')}`);
});