Files
KantSites/tests/editor-model.test.mjs
T
kantarciandClaude Opus 5 82b07820bc Kant CMS: Grundgerüst, visueller Editor, Mediathek
Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting.
Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen,
getrennten Entwürfen/Veröffentlichungen und optimistischer
Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau,
responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek,
Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript.

Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:19:05 +02:00

124 lines
7.6 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 } 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());
});