Migration 20260912000600 ergänzt page.excerpt, author,
featured_media_id und published_at sowie die Tabellen term und
page_term. Beitragsmetadaten beschreiben die Seite, nicht ihren
Inhalt, und liegen deshalb als Spalten statt im Blockdokument: das
Archiv sortiert und paginiert damit in SQL, statt für jede
Auflistung alle Dokumente zu dekodieren.
Kategorien und Schlagwörter teilen sich eine Tabelle mit einem
kind-Feld. Beim Löschen einer Seite verschwinden ihre Zuordnungen,
die Begriffe bleiben als wiederverwendbare Etiketten; ein leer
gewordenes Archiv antwortet weiter mit 200 und leerer Liste.
Der Block postlist (Dokumentschema v5) speichert nur Anzahl und
Filter. Die Beiträge stellt der Controller beim Ausliefern zusammen,
damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag
zeigen kann. Archive unter /beitraege, /kategorie/{slug} und
/schlagwort/{slug}, Feed unter /feed.xml; alle vier Segmente sind
für Wurzelseiten reserviert.
Geprüft: 91 PHP-Tests / 347 Assertions unter PHP 8.2.12 und 8.4.22,
12 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
11 KiB
JavaScript
179 lines
11 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);
|
|
});
|
|
|
|
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);
|
|
});
|