Umut fragte nach Zeilen, nach Rahmen und Rundungen - und ob der Designer so aufgestellt ist wie Flatsome. Der Vergleich steht in docs/DESIGNER-VERGLEICH.md, mit der Einschraenkung, dass Flatsome hier nicht installierbar war: das ist Wissen, keine Messung. Zwei echte Fehler kamen dabei heraus, beide vorher unbemerkt. Eine Rahmenstaerke je Seite wurde zu border-width-top. Das ist kein CSS, der Browser verwirft die Zeile stillschweigend - Rahmenstaerken je Seite hatten nie eine Wirkung, und es gab keine Meldung, die darauf hingewiesen haette. Die Schreibweise steht jetzt je Eigenschaft im Schema, weil padding-top dem Namen folgt und border-top-width nicht. Und eine Rundung auf einem Bildblock landete auf dem figure, waehrend das Bild darin eckig blieb. Eigenschaften koennen jetzt auf das Bild im Block zielen (target 'img' im Schema, eine zweite Regel im Compiler) - deshalb tut object-fit ueberhaupt etwas, denn auf einem figure tut es nichts. Von 50 auf 93 Eigenschaften: Bild (Ausschnitt, Bildmitte, Seitenverhaeltnis, Hoehe, Ecken, Rahmen), Positionierung mit z-index, getrennte Zeilen- und Spaltenabstaende, Flex je Element, Innen- und Textschatten, acht Bildfilter als eine filter-Deklaration in fester Reihenfolge, Mischmodus, Zusatzrahmen, Mauszeiger. Neun Einblendungen mit Verzoegerung, sieben Hover-Effekte, alle innerhalb von prefers-reduced-motion. Drei neue Bausteine (Dokumentschema v15): Symbol als eigenes Element, Fortschritt mit role=meter und dem Wert im Markup statt nur in der Balkenbreite, und ein Slider als Scrollbereich mit Einrasten - ohne JavaScript, ohne Autoplay, mit Finger, Trackpad, Mausrad und Tastatur. Zeilen waren nicht unauffindbar, sondern unbrauchbar leer: eine Zeile ohne Spalten sieht aus wie nichts. Jetzt legt "Zeile, 2/3/4 Spalten" beides auf einen Klick an, und im Werkzeugkasten steht, wie der Aufbau gemeint ist. 315 PHP-Tests / 1309 Assertions, 16 JS-Modelltests, axe auf dreizehn Bildschirmen ohne Verstoesse. 16 Datensaetze rechnen jede Eigenschaftsform gegen die erwartete Deklaration nach - die Pruefung, die die beiden Fehler oben gefunden haette, wenn es sie vorher gegeben haette. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305 lines
17 KiB
JavaScript
305 lines
17 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle } from '../public/assets/editor-model.js';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { designCss } from '../scripts/build-design.mjs';
|
|
|
|
function fixture() { return { schemaVersion: 1, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }, { id: 't1', type: 'text', props: { text: 'Body' } }] }, { id: 's2', type: 'section', children: [] }] }; }
|
|
|
|
test('typing coalesces into one undo and redo; saved state tracks content, not stack length', () => {
|
|
const history = new EditorHistory(fixture());
|
|
history.change(doc => doc.title = 'New', 'title');
|
|
history.change(doc => doc.title = 'New title', 'title');
|
|
assert.equal(history.past.length, 1);
|
|
history.undo(); assert.equal(history.document.title, 'Page'); assert.equal(history.dirty, false);
|
|
history.redo(); assert.equal(history.document.title, 'New title'); assert.equal(history.dirty, true);
|
|
history.markSaved(history.document); assert.equal(history.dirty, false);
|
|
history.undo(); assert.equal(history.dirty, true);
|
|
history.redo(); assert.equal(history.dirty, false);
|
|
});
|
|
|
|
test('new edit after undo drops redo and source snapshots remain immutable', () => {
|
|
const original = fixture(); const history = new EditorHistory(original);
|
|
history.change(doc => locate(doc.blocks, 't1').block.props.text = 'changed');
|
|
history.undo(); history.change(doc => doc.title = 'Branch');
|
|
assert.equal(history.future.length, 0); assert.equal(original.title, 'Page');
|
|
assert.equal(locate(original.blocks, 't1').block.props.text, 'Body');
|
|
});
|
|
|
|
test('add into selected container, add after leaf, and duplicate assigns fresh IDs recursively', () => {
|
|
const document = fixture();
|
|
const id = insertBlock(document, 'text', 's2'); assert.equal(locate(document.blocks, id).parent.id, 's2');
|
|
const after = insertBlock(document, 'button', 't1'); assert.equal(document.blocks[0].children[2].id, after);
|
|
const copy = duplicateBlock(document, 's1');
|
|
const copied = locate(document.blocks, copy).block;
|
|
assert.notEqual(copied.children[0].id, 'h1'); assert.equal(copied.children[0].props.level, 2);
|
|
assert.equal(document.blocks[0].children[0].props.level, 1);
|
|
});
|
|
|
|
test('moving prevents self/descendant cycles and supports cross-container moves', () => {
|
|
const document = fixture();
|
|
assert.equal(moveBlock(document, 's1', 'h1', true), false);
|
|
assert.equal(moveBlock(document, 's1', 's1', true), false);
|
|
assert.equal(moveBlock(document, 'h1', 't1', true), false);
|
|
assert.equal(moveBlock(document, 't1', 's2', true), true);
|
|
assert.equal(locate(document.blocks, 't1').parent.id, 's2');
|
|
assert.equal(moveBlock(document, 's2', 's1'), true);
|
|
assert.equal(document.blocks[0].id, 's2');
|
|
});
|
|
|
|
test('history persists exact block document after add, move, delete, undo and JSON reload', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const id = history.change(doc => insertBlock(doc, 'image', 's2'));
|
|
history.change(doc => moveBlock(doc, id, 't1'));
|
|
const saved = structuredClone(history.document);
|
|
history.change(doc => { const target = locate(doc.blocks, id); target.siblings.splice(target.index, 1); });
|
|
history.undo(); assert.deepEqual(history.document, saved);
|
|
const loaded = new EditorHistory(JSON.parse(JSON.stringify(history.document)));
|
|
assert.deepEqual(loaded.document, saved); assert.equal(loaded.dirty, false);
|
|
assert.throws(() => createBlock('unknown'));
|
|
});
|
|
|
|
test('v1 to v2 migration preserves all content and IDs, is idempotent and rejects future versions', () => {
|
|
const original = fixture(); const migrated = upgradeDocument(structuredClone(original));
|
|
assert.deepEqual(migrated, { ...original, schemaVersion: 2 });
|
|
assert.deepEqual(upgradeDocument(structuredClone(migrated)), migrated);
|
|
assert.throws(() => upgradeDocument({ ...original, schemaVersion: 999 }));
|
|
assert.equal(original.schemaVersion, 1);
|
|
});
|
|
|
|
test('responsive inheritance, reset, migration undo, and reload preserve distinct device values', () => {
|
|
const history = new EditorHistory(fixture());
|
|
history.change(doc => setDesign(doc, 'h1', 'desktop', 'fontSize', 'hero'));
|
|
assert.equal(history.document.schemaVersion, 2);
|
|
const heading = () => locate(history.document.blocks, 'h1').block;
|
|
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'hero', source: 'desktop' });
|
|
history.undo(); assert.deepEqual(history.document, fixture()); history.redo();
|
|
history.change(doc => setDesign(doc, 'h1', 'tablet', 'fontSize', 'display'));
|
|
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', 'title'));
|
|
assert.equal(effectiveDesign(heading(), 'desktop', 'fontSize').value, 'hero');
|
|
assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
|
|
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', ''));
|
|
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'display', source: 'tablet' });
|
|
history.undo(); assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
|
|
assert.deepEqual(JSON.parse(JSON.stringify(history.document)), history.document);
|
|
});
|
|
|
|
test('presets insert after containing section, keep a single H1, unique IDs, and undo atomically', () => {
|
|
const history = new EditorHistory(fixture());
|
|
for (const name of ['intro', 'columns', 'cta']) {
|
|
const id = history.change(doc => insertPreset(doc, name, 'h1'));
|
|
assert.equal(history.document.blocks[1].id, id);
|
|
assert.deepEqual(blockPath(history.document.blocks, 'h1').map(block => block.id), ['s1', 'h1']);
|
|
}
|
|
const nodes = [];
|
|
const visit = blocks => blocks.forEach(block => { nodes.push(block); visit(block.children || []); });
|
|
visit(history.document.blocks);
|
|
assert.equal(new Set(nodes.map(block => block.id)).size, nodes.length);
|
|
assert.equal(nodes.filter(block => block.type === 'heading' && block.props.level === 1).length, 1);
|
|
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('committed CSS matches the shared validated token manifest', async () => {
|
|
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
|
|
assert.equal(await readFile(new URL('../public/assets/design.css', import.meta.url), 'utf8'), designCss(tokens));
|
|
});
|
|
|
|
test('v3 media and effects keep originals, gallery restrictions, and atomic undo', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const media = { id: 'a'.repeat(32), alt: 'Landscape', variants: [{src: '/media/a/480.webp', width: 480, height: 320}] };
|
|
const gallery = history.change(doc => insertMedia(doc, 's2', [media, media]));
|
|
assert.equal(history.document.schemaVersion, 3);
|
|
const children = locate(history.document.blocks, gallery).block.children;
|
|
assert.equal(children.length, 2); assert.notEqual(children[0].id, children[1].id);
|
|
assert.equal(moveBlock(history.document, 'h1', gallery, true), false);
|
|
assert.equal(moveBlock(history.document, 'h1', children[0].id), false);
|
|
const text = history.change(doc => insertBlock(doc, 'text', children[0].id));
|
|
assert.notEqual(locate(history.document.blocks, text).parent?.id, gallery);
|
|
history.undo();
|
|
history.change(doc => setEffect(doc, gallery, 'shadow', 'soft'));
|
|
history.change(doc => setDesign(doc, gallery, 'mobile', 'columns', 'one'));
|
|
assert.equal(history.document.schemaVersion, 3);
|
|
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('plugin blocks stay movable and intact while the editor refuses to fill or edit them', () => {
|
|
const plugin = { id: 'p1', type: 'acme.slider', props: { slides: [{ headline: 'From the plugin' }] }, children: [{ id: 'pc1', type: 'text', props: { text: 'Nested' } }] };
|
|
const start = { schemaVersion: 4, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }] }, structuredClone(plugin)] };
|
|
const history = new EditorHistory(structuredClone(start));
|
|
|
|
assert.equal(isPluginBlock(plugin), true);
|
|
assert.equal(isPluginBlock({ type: 'section' }), false);
|
|
for (const type of ['Acme.Slider', 'acme.', '.slider', 'acme/slider', 'section']) assert.equal(isPluginBlock({ type }), false, type);
|
|
assert.equal(blockLabel(plugin), 'Baustein acme.slider');
|
|
assert.equal(blockIcon(plugin), '❔');
|
|
assert.equal(blockLabel({ type: 'section' }), 'Sektion');
|
|
|
|
// Nothing may be dropped into a block whose layout contract the core does not know.
|
|
assert.equal(canContain(plugin, { type: 'text' }), false);
|
|
assert.equal(isContainer(plugin), false);
|
|
assert.equal(moveBlock(history.document, 'h1', 'p1', true), false);
|
|
assert.throws(() => createBlock('acme.slider'), /Unbekannter Block/);
|
|
|
|
// Moving and duplicating preserve the stored block including its unknown props and children.
|
|
assert.equal(history.change(doc => moveBlock(doc, 'p1', 's1', true)), true);
|
|
assert.equal(locate(history.document.blocks, 'p1').parent.id, 's1');
|
|
const copy = history.change(doc => duplicateBlock(doc, 'p1'));
|
|
const duplicate = locate(history.document.blocks, copy).block;
|
|
assert.notEqual(duplicate.id, 'p1');
|
|
assert.notEqual(duplicate.children[0].id, 'pc1');
|
|
assert.deepEqual(duplicate.props, plugin.props);
|
|
assert.deepEqual(locate(history.document.blocks, 'p1').block.props, plugin.props);
|
|
|
|
// A full undo must restore the plugin block byte for byte.
|
|
history.undo(); history.undo();
|
|
assert.deepEqual(history.document, start);
|
|
assert.equal(upgradeDocument(history.document, 4).schemaVersion, 4);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(history.document)).blocks.find(b => b.id === 'p1'), plugin);
|
|
});
|
|
|
|
test('the post list block carries only its filter, lifts the schema to v5 and undoes atomically', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const id = history.change(doc => insertBlock(doc, 'postlist', 's2'));
|
|
const block = locate(history.document.blocks, id).block;
|
|
|
|
// Content is the filter and the count; the posts themselves are added when the page is served.
|
|
assert.deepEqual(block.props, { count: 3, category: '', tag: '' });
|
|
assert.equal(block.children, undefined);
|
|
assert.equal(history.document.schemaVersion, 5);
|
|
|
|
// Nothing can be nested inside it, and it does not swallow a following block.
|
|
assert.equal(canContain(block, { type: 'text' }), false);
|
|
const text = history.change(doc => insertBlock(doc, 'text', id));
|
|
assert.notEqual(locate(history.document.blocks, text).parent?.id, id);
|
|
|
|
history.undo(); history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
assert.equal(history.document.schemaVersion, 1);
|
|
});
|
|
|
|
test('v6 containers accept only their own children and an accordion starts with one entry', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const accordion = history.change(doc => insertBlock(doc, 'accordion', 's2'));
|
|
const block = locate(history.document.blocks, accordion).block;
|
|
|
|
assert.equal(history.document.schemaVersion, 6);
|
|
// An empty accordion would render as nothing, so it comes with one entry.
|
|
assert.equal(block.children.length, 1);
|
|
assert.equal(block.children[0].type, 'accordionitem');
|
|
assert.deepEqual(block.children[0].props, { title: 'Eine Frage', open: false });
|
|
|
|
// Only accordion entries go into an accordion, and an entry goes nowhere else.
|
|
assert.equal(canContain(block, { type: 'text' }), false);
|
|
assert.equal(canContain(block, { type: 'accordionitem' }), true);
|
|
assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false);
|
|
assert.equal(canContain(block.children[0], { type: 'text' }), true);
|
|
|
|
// A logo strip takes images only, like a gallery.
|
|
const logos = history.change(doc => insertBlock(doc, 'logos', 's2'));
|
|
const strip = locate(history.document.blocks, logos).block;
|
|
assert.equal(canContain(strip, { type: 'image' }), true);
|
|
assert.equal(canContain(strip, { type: 'iconcard' }), false);
|
|
|
|
// Leaf blocks stay leaves and keep their defaults.
|
|
// Two statements on purpose: history.change() replaces the document, so reading
|
|
// history.document.blocks in the same expression would search the previous snapshot.
|
|
const statId = history.change(doc => insertBlock(doc, 'stat', 's2'));
|
|
const stat = locate(history.document.blocks, statId).block;
|
|
assert.equal(stat.children, undefined);
|
|
assert.deepEqual(stat.props, { value: '15+', label: 'Jahre Erfahrung' });
|
|
|
|
history.undo(); history.undo(); history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('a new tab group starts with two tabs and accepts nothing but tabs', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const id = history.change(doc => insertBlock(doc, 'tabs', 's2'));
|
|
const group = locate(history.document.blocks, id).block;
|
|
|
|
assert.equal(history.document.schemaVersion, 7);
|
|
// One tab is not a tab group, and the enhancement needs at least two to do anything.
|
|
assert.equal(group.children.length, 2);
|
|
assert.deepEqual(group.children.map(c => c.type), ['tabitem', 'tabitem']);
|
|
assert.notEqual(group.children[0].id, group.children[1].id);
|
|
assert.deepEqual(group.children[0].props, { title: 'Neuer Tab' });
|
|
|
|
assert.equal(canContain(group, { type: 'tabitem' }), true);
|
|
assert.equal(canContain(group, { type: 'text' }), false);
|
|
assert.equal(canContain({ type: 'section', children: [] }, { type: 'tabitem' }), false);
|
|
assert.equal(canContain({ type: 'accordion', children: [] }, { type: 'tabitem' }), false);
|
|
// A tab itself holds ordinary content.
|
|
assert.equal(canContain(group.children[0], { type: 'text' }), true);
|
|
|
|
history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('free styles inherit from the wider device, reset individually and leave no skeleton', () => {
|
|
const history = new EditorHistory(fixture());
|
|
history.change(doc => setStyle(doc, 's1', 'desktop', 'fontSize', '42px'));
|
|
history.change(doc => setStyle(doc, 's1', 'desktop', 'padding', '80px', { side: 'top' }));
|
|
history.change(doc => setStyle(doc, 's1', 'desktop', 'color', '#ffffff', { hover: true }));
|
|
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', '28px'));
|
|
const block = () => locate(history.document.blocks, 's1').block;
|
|
|
|
assert.equal(history.document.schemaVersion, 10);
|
|
// Tablet has no own value, so it inherits the desktop one; mobile keeps its own.
|
|
assert.deepEqual(effectiveStyle(block(), 'tablet', 'fontSize'), { value: '42px', source: 'desktop' });
|
|
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '28px', source: 'mobile' });
|
|
assert.deepEqual(effectiveStyle(block(), 'desktop', 'padding', 'top'), { value: '80px', source: 'desktop' });
|
|
assert.equal(hoverStyle(block(), 'desktop', 'color'), '#ffffff');
|
|
|
|
// Clearing one value makes it inherit again and removes the now empty container.
|
|
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', ''));
|
|
assert.equal(block().style.mobile, undefined);
|
|
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '42px', source: 'desktop' });
|
|
|
|
// Copying carries the styling over without sharing objects.
|
|
history.change(doc => copyStyle(doc, 's1', 's2'));
|
|
const copy = locate(history.document.blocks, 's2').block;
|
|
assert.deepEqual(copy.style, block().style);
|
|
assert.notEqual(copy.style, block().style);
|
|
|
|
history.change(doc => clearStyle(doc, 's1'));
|
|
assert.equal(block().style, undefined);
|
|
|
|
// Every step is one history entry, so undoing all of them restores the original document.
|
|
for (let i = 0; i < 7; i++) history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('a row arrives with its columns, inside the selected container', () => {
|
|
const history = new EditorHistory(fixture());
|
|
|
|
// With a section selected, the row becomes a child of it - that is where people expect it.
|
|
const rowId = history.change(doc => insertRow(doc, 3, 's1'));
|
|
const row = locate(history.document.blocks, rowId);
|
|
assert.equal(row.block.type, 'row');
|
|
assert.equal(row.parent.id, 's1');
|
|
assert.equal(row.block.children.length, 3);
|
|
assert.ok(row.block.children.every(child => child.type === 'column'));
|
|
assert.equal(row.block.layout.design.desktop.columns, 'three');
|
|
assert.equal(row.block.layout.design.mobile.columns, 'one');
|
|
// Column counts live in the design tokens, which need schema v2.
|
|
assert.ok(history.document.schemaVersion >= 2);
|
|
|
|
// With a heading selected, the row is placed after it rather than inside it.
|
|
const second = history.change(doc => insertRow(doc, 2, 'h1'));
|
|
const placed = locate(history.document.blocks, second);
|
|
assert.equal(placed.parent.id, 's1');
|
|
assert.equal(placed.siblings[placed.index - 1].id, 'h1');
|
|
assert.equal(placed.block.children.length, 2);
|
|
|
|
// Out of range counts are clamped instead of producing nonsense. Two statements, because
|
|
// history.change replaces the document: reading blocks in the same expression reads the old one.
|
|
const wideId = history.change(doc => insertRow(doc, 99, 's2'));
|
|
assert.equal(locate(history.document.blocks, wideId).block.children.length, 6);
|
|
const narrowId = history.change(doc => insertRow(doc, 0, 's2'));
|
|
assert.equal(locate(history.document.blocks, narrowId).block.children.length, 2, 'An unusable number falls back to two.');
|
|
|
|
// Each insertion is one history entry.
|
|
for (let i = 0; i < 4; i++) history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
});
|