The kant-it.de header used the hamburger switch at schema 19, so the server refused its preview and save; the editor now raises a loaded document to the version its blocks and properties require. The admin, button and navigation checks select blocks through the structure tab, find the motion button by id and expect what the site's motion setting says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
660 lines
39 KiB
JavaScript
660 lines
39 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, registerPlugins, pluginDefinition } 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);
|
|
// Column shares belong to a row and to nothing else - not even to the column itself.
|
|
assert.equal(relevantProperty(createBlock('row'), 'columnWidths', { group: 'layout' }), true);
|
|
assert.equal(relevantProperty(createBlock('column'), 'columnWidths', { group: 'layout' }), false);
|
|
assert.equal(relevantProperty(text, 'columnWidths', { 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('the own-HTML block starts empty, is offered in the toolbox and lifts the document to v24', () => {
|
|
// Eingefuegt wird er leer: bis jemand Code eintraegt, steht nichts auf der Seite.
|
|
const history = new EditorHistory(fixture());
|
|
const gruppe = CATEGORIES.find(category => category.types.includes('html'));
|
|
assert.equal(gruppe?.name, 'Seite und Daten');
|
|
assert.equal(blockLabels.html, 'Eigenes HTML');
|
|
const id = history.change(doc => insertBlock(doc, 'html', 's1'));
|
|
assert.equal(locate(history.document.blocks, id).block.props.html, '');
|
|
assert.equal(history.document.schemaVersion, 24);
|
|
});
|
|
|
|
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 v18 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);
|
|
// v18, weil der Server die Darstellung "karten" erst ab dieser Fassung annimmt.
|
|
assert.equal(history.document.schemaVersion, 18);
|
|
|
|
// 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.
|
|
// Die Zahlen sind die des Servers fuer die Eigenschaften, die createBlock setzt (Beitragsliste
|
|
// mit Darstellung: v18, Menue mit Art: v16, Slider mit Punkten: v17) - nicht die erste Fassung,
|
|
// die den Typ kannte.
|
|
for (const [type, needed] of [['gallery', 3], ['postlist', 18], ['quote', 6], ['tabs', 7], ['form', 8], ['breadcrumb', 9], ['menu', 16], ['partialref', 11], ['embed', 14], ['icon', 15], ['progress', 15], ['slider', 17], ['social', 23]]) {
|
|
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 lives in a row or alone in a section', () => {
|
|
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 einzelne Spalte darf direkt in einer Sektion stehen - so wird ein Button zentriert -,
|
|
// aber nicht in einer Spalte oder lose im Dokument.
|
|
assert.ok(insertBlockAt(document, 'column', 's2', 0));
|
|
assert.equal(insertBlockAt(document, 'column', row.children[0].id, 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(', ')}`);
|
|
});
|
|
|
|
test('registered plugin blocks get labels, defaults, container rules and presets with fresh ids', () => {
|
|
registerPlugins([
|
|
{ type: 'vergleich.bildvergleich', label: 'Bildvergleich', icon: '⇔', container: false, children: null, props: { position: { kind: 'number', default: 50 }, before: { kind: 'media', default: '' } } },
|
|
{ type: 'vergleich.galerie', label: 'Vergleichsgalerie', icon: '⊞', container: true, children: ['vergleich.bildvergleich'], props: {} },
|
|
], [{ id: 'plugin:vergleich:abschnitt', label: 'Abschnitt', plugin: 'Bildvergleich', blocks: [{ id: 'fix', type: 'section', children: [{ id: 'fix2', type: 'vergleich.bildvergleich', props: { position: 30 } }] }] }]);
|
|
const bild = createBlock('vergleich.bildvergleich');
|
|
assert.deepEqual(bild.props, { position: 50, before: '' });
|
|
assert.equal('children' in bild, false);
|
|
const galerie = createBlock('vergleich.galerie');
|
|
assert.deepEqual(galerie.children, []);
|
|
assert.equal(isContainer(galerie), true);
|
|
assert.equal(canContain(galerie, bild), true, 'the manifest allows the comparison');
|
|
assert.equal(canContain(galerie, { type: 'text' }), false, 'and nothing else');
|
|
assert.equal(canContain(bild, { type: 'text' }), false, 'a plain plugin block takes no children');
|
|
assert.equal(pluginDefinition(galerie).label, 'Vergleichsgalerie');
|
|
|
|
const document = { schemaVersion: 4, title: 'P', description: 'D', blocks: [{ id: 's1', type: 'section', children: [] }] };
|
|
const first = insertPreset(document, 'plugin:vergleich:abschnitt', 's1');
|
|
const second = insertPreset(document, 'plugin:vergleich:abschnitt', 's1');
|
|
assert.notEqual(first, second, 'every insertion gets its own ids');
|
|
assert.notEqual(first, 'fix');
|
|
assert.equal(document.blocks.length, 3);
|
|
assert.equal(locate(document.blocks, first).block.children[0].props.position, 30);
|
|
assert.notEqual(locate(document.blocks, first).block.children[0].id, locate(document.blocks, second).block.children[0].id);
|
|
|
|
// Switched off again: the type is unknown once more and the block a placeholder.
|
|
registerPlugins([], []);
|
|
assert.equal(pluginDefinition(bild), null);
|
|
assert.equal(isContainer(galerie), false);
|
|
});
|
|
|
|
test('a loaded document is raised to the schema its blocks and properties need', async () => {
|
|
const { repairSchema } = await import('../public/assets/editor-model.js');
|
|
// So stand der Kopfbereich von kant-it.de da: Hamburger-Umschaltung bei Fassung 19.
|
|
const header = { schemaVersion: 19, title: 'Kopf', description: '', blocks: [
|
|
{ id: 's', type: 'section', anchor: 'oben', children: [{ id: 'm', type: 'menu', props: { slot: 'header', collapse: 'tablet' } }] },
|
|
] };
|
|
assert.equal(repairSchema(header).schemaVersion, 22);
|
|
// Nichts Neues darin: die Fassung bleibt - auch die Umstellung auf formatierten Text (12)
|
|
// kommt nie von allein.
|
|
const plain = { schemaVersion: 5, title: 'Alt', description: '', blocks: [{ id: 't', type: 'text', props: { text: 'a < b' } }] };
|
|
assert.equal(repairSchema(plain).schemaVersion, 5);
|
|
const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] };
|
|
assert.equal(repairSchema(faq).schemaVersion, 23);
|
|
});
|