Files
KantSites/tests/editor-model.test.mjs
T
kantarciandClaude Opus 5 22e4ada830
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
Fassung 1.5.0: die Vorschau wird zur Arbeitsflaeche
Abgleich mit dem Flatsome UX Builder (docs/FLATSOME-ABGLEICH.md): die Gestaltung
ist Flatsome voraus, die Luecken liegen beim Elementkatalog und bei der
Bedienung. Umuts Entscheidung: Bedienung zuerst, Shop-Elemente ausgelassen.

Erste Haelfte der Bedienung:

- Werkzeugleiste am Element in der Vorschau. Sie nennt das Element, auf das sie
  wirkt - die Seitenleiste hat dieselben fuenf Aktionen, und zwei gleichnamige
  Bedienelemente auf einem Bildschirm sind fuer eine Sprachausgabe nicht
  unterscheidbar. Beide Wege rufen dieselben Funktionen auf.
- Auswahl- und Ueberfahrrahmen mit Beschriftung.
- Einfuegepunkte an Ober- und Unterkante: das naechste Element landet dort
  statt am Ende.
- Ziehen aus dem Werkzeugkasten in die Vorschau, mit Ablegemarke zwischen den
  Elementen und Rahmen um einen aufnehmenden Behaelter. Der Strukturbaum zieht
  in dieselbe Flaeche.

Die Ebene liegt ueber dem Rahmen, nicht darin: die Vorschau wird bei jeder
Aenderung ersetzt, eingezeichnete Bedienelemente waeren weg und stuenden in der
Auszeichnung, die die Vorschau wahrheitsgetreu zeigen soll. Leiste und
Pluszeichen werden gegenskaliert und bleiben in der Mobilvorschau fingergross.

Im Modell neu: insertBlockAt, moveBlockTo, dropList.

Dabei behoben: die Ebene war aria-hidden und enthielt fokussierbare
Schaltflaechen; canContain zerbrach an einem fehlenden Block beim Ziehen aus
dem Werkzeugkasten; beim Verschieben ans Listenende wurde der Index begrenzt,
bevor die Entnahme verrechnet war.

Geprueft: npm test 22/22, phpunit 434/434, npm run test:editor vollstaendig
mit neuem Abschnitt und ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:53:23 +02:00

428 lines
24 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, 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';
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);
// 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('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));
});