Der Lesevalidator wies bisher jeden unbekannten Blocktyp ab, womit der Fallback im Renderer unerreichbar war: eine Seite mit Bausteinen aus einer deaktivierten Erweiterung wäre nicht mehr ladbar gewesen. Ab Schema v4 gilt ein Typ mit Namensraum (acme.slider) als Baustein einer Erweiterung und bleibt erhalten. Der Kern prüft weiterhin ID, Tiefe, Blockanzahl und Dokumentgröße, aber bewusst nicht die Props - er kennt das Schema der Erweiterung nicht. Damit ungeprüfte Werte nie ausgegeben werden, rendert der Kern aus einem solchen Block nichts: öffentlich nur ein HTML-Kommentar, in der Editorvorschau ein Platzhalter. Im Editor bleiben die Blöcke auswählbar, verschiebbar, duplizierbar und löschbar, aber nicht bearbeitbar, und es lässt sich nichts in sie einfügen. Testumgebung: Twig lädt Vorlagen jetzt automatisch neu. Ohne Debug konnte eine kompilierte Vorlage im Cache den Ausgang eines Testlaufs bestimmen. Geprüft: 64 PHP-Tests / 229 Assertions unter PHP 8.2.12 und 8.4.22, 11 JavaScript-Modelltests, Lint/Format/Container. Keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
9.8 KiB
JavaScript
159 lines
9.8 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer } 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);
|
|
});
|