Files
Endoleap/tests/headless/creation-size-plan.test.mjs
T

178 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 异步获知图片默认尺寸后修正创建历史的规划测试。plan只生成候选document/history,不能修改已发intent或凭空添加撤销单元。
* 区分用户显式resize、单位换算、移动和redo分支,检查整个undo/redo路径仍可恢复,不以当前尺寸碰巧相同判定未改过。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDocument, createEditor, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
import { planCreationSize } from '../../src/lib/canvas-core/creation-size-plan.ts';
import { applyChanges, invert } from '../../src/lib/canvas-core/document.ts';
import { describeHistoryTransition, applyHistoryTransition } from '../../src/lib/canvas-core/history-transition.ts';
const registry = createRegistry(builtinComponents());
/** 固定image第一生命周期,删除恢复后这个异步任务目标应失效。 */
const target = { id: 'image', incarnation: 1 };
/** 先创建260×200图片并记录创建history entry,propose统一尝试改默认值为260×130,允许覆盖editor依赖。 */
function setup(options = {}) {
let id = 0;
const editor = createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], []), registry, createId: () => `id-${++id}`, ...options });
const element = { ...target, ownerCanvasId: 'root', componentId: 'image', dataVersion: 1, structure: 'node', data: { title: 'Image', text: '', assetId: 'asset' }, geometry: { x: 10, y: 20, width: 260, height: 200, rotation: 0 } };
assert.equal(editor.dispatch({ type: 'element.create', element }).status, 'applied');
const entry = editor.getSnapshot().history.undo[0];
return { editor, entry, propose() { const { document, history } = editor.getSnapshot(); return planCreationSize(document, history, registry, entry, target, { width: 260, height: 130 }); } };
}
/** 从plan当前状态逆序撤销到空,再按undo顺序重做,对照最终图片;只适用于不含额外初始元素的调用。 */
// 直接应用历史 changes/invert 来检查候选前后值连续性;不走 editor.history,也不模拟其恢复对象时的 incarnation 分配。
function traverse(plan) {
let document = plan.document;
for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
assert.equal(document.elements.size, 0);
for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes);
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
return document;
}
// 创建后移位/旋转/改名,默认尺寸修正保留这些编辑与栈顺序;原editor根和序列化intent均不变。
test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => {
const { editor, entry, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { x: 80, rotation: 25 } });
editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed' } });
const before = editor.getSnapshot(), intents = [...before.operations.values()].map(value => JSON.stringify(value));
const plan = propose();
assert.deepEqual(plan.history.undo, before.history.undo);
assert.equal(plan.history.entries.get(entry).changes[0].after.geometry.height, 130);
assert.deepEqual(plan.document.elements.get('image').geometry, { x: 80, y: 20, width: 260, height: 130, rotation: 25 });
assert.equal(plan.document.elements.get('image').data.title, 'Renamed');
assert.strictEqual(editor.getSnapshot(), before);
assert.deepEqual([...before.operations.values()].map(value => JSON.stringify(value)), intents);
traverse(plan);
});
// 位置变化撤销后仍在redo,规划保留redo且当前x不提前跳到未来值;实际重做再得到90与新默认高度。
test('an unrelated redo stack is retained and rebased without becoming the working document', () => {
const { editor, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { x: 90 } }); editor.history.undo();
const before = editor.getSnapshot(), plan = propose();
assert.deepEqual(plan.history.redo, before.history.redo);
assert.equal(plan.document.elements.get('image').geometry.x, 10);
let document = traverse(plan);
document = applyChanges(document, plan.history.entries.get(plan.history.redo.at(-1)).changes);
assert.equal(document.elements.get('image').geometry.x, 90);
assert.equal(document.elements.get('image').geometry.height, 130);
});
// resize已撤销时允许修默认基线;redo仍用用户300高度,逆向回到修正后的130。
test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => {
const { editor, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { width: 400, height: 300 } }); editor.history.undo();
const plan = propose(), resize = plan.history.entries.get(plan.history.redo.at(-1));
const resized = applyChanges(plan.document, resize.changes);
assert.equal(resized.elements.get('image').geometry.height, 300);
assert.equal(applyChanges(resized, invert(resize.changes)).elements.get('image').geometry.height, 130);
});
// 用户先改高400再改回200,仍属于显式调整,不得以最终值相同覆盖其意图。
test('applied resize rejects even after a later resize returns to the initial dimensions', () => {
const { editor, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { height: 400 } });
editor.dispatch({ type: 'element.geometry', target, patch: { height: 200 } });
const before = editor.getSnapshot();
assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before);
});
// 创建已撤销,原图片不存在,异步默认尺寸任务以target_changed拒绝。
test('an undone creation cannot be enriched', () => {
const { editor, propose } = setup(); editor.history.undo();
assert.throws(propose, { code: 'creation_size_target_changed' });
});
// 删除再恢复使生命周期变化,即使ID相同,旧创建尺寸任务也不能修改新对象。
test('a restored element has a new incarnation and rejects the old asynchronous task', () => {
const { editor, propose } = setup(); editor.dispatch({ type: 'element.remove', target }); editor.history.undo();
// 删除撤销会恢复为新 incarnation,原 target 仍绑定第一代,因此旧尺寸任务必须失效。
assert.throws(propose, { code: 'creation_size_target_changed' });
});
// 用已有historyTransition格式描述plan,JSON往返后正反回放仍精确相等,entries不暴露set。
test('the complete history proposal is reversible using the existing transition proof format', () => {
const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } });
const before = editor.getSnapshot(), plan = propose();
const transition = describeHistoryTransition(before.history, plan.history, before.rootRevision + 1, entry);
const replay = applyHistoryTransition(before.history, JSON.parse(JSON.stringify(transition)), 'forward');
assert.deepEqual([...replay.entries], [...plan.history.entries]);
const restored = applyHistoryTransition(replay, transition, 'reverse');
assert.deepEqual([...restored.entries], [...before.history.entries]);
assert.equal(plan.history.entries.set, undefined);
});
// 允许raw changes构造单位×2与几何同比变化,修正尺寸按单位换算而非误判成用户resize,保留最新位置并遍历历史。
test('a logical unit conversion preserves size, latest position and the full undo path', () => {
const { editor, propose } = setup({ allowRawChanges: true });
const before = editor.getSnapshot().document.elements.get('image');
editor.transaction(tx => {
tx.dispatch({ type: 'changes.apply', changes: [
{ kind: 'field', target, field: 'geometry', before: before.geometry, after: { ...before.geometry, x: 45, width: 520, height: 400 } },
{ kind: 'field', target, field: 'data.presentation', before: null, beforePresent: false, after: { unitScale: 2 } },
] });
});
const plan = propose();
assert.equal(plan.document.elements.get('image').geometry.height, 260);
assert.equal(plan.document.elements.get('image').geometry.x, 45);
traverse(plan);
});
// 图片移入contentScale=2子图后仍保留owner/位置,默认高度换到局部65;undo只移图片不删初始frame,再重做一致。
test('a real move into a scaled child canvas is retained through enrichment and undo', () => {
const frame = { id: 'frame', incarnation: 1, ownerCanvasId: 'root', componentId: 'frame', dataVersion: 1, structure: 'node', data: { title: 'Frame', text: '', presentation: { contentScale: 2 } }, geometry: { x: 0, y: 0, width: 600, height: 400, rotation: 0 } };
const { editor, propose } = setup({ document: createDocument('p', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [frame]) });
assert.equal(editor.dispatch({ type: 'element.move', target, canvasId: 'child' }).status, 'applied');
const current = editor.getSnapshot().document.elements.get('image'), plan = propose();
assert.equal(plan.document.elements.get('image').ownerCanvasId, 'child');
assert.equal(plan.document.elements.get('image').geometry.x, current.geometry.x);
assert.equal(plan.document.elements.get('image').geometry.height, 65);
let document = plan.document;
for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
assert.equal(document.elements.has('image'), false); assert.equal(document.elements.has('frame'), true);
for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes);
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
});
// historyLimit=1淘汰创建条目后不能修补,也不能为了异步结果补造历史单元。
test('evicted creation history is rejected without synthesizing a new undo entry', () => {
const { editor, propose } = setup({ historyLimit: 1 });
editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } });
assert.throws(propose, { code: 'creation_size_target_changed' });
});
// resize与移动都撤销后,按redo正确次序重放保留用户300高和x=75,再反向回到plan状态。
test('a future resize followed by movement retains the user size through both redo entries', () => {
const { editor, propose } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
editor.dispatch({ type: 'element.geometry', target, patch: { x: 75 } });
editor.history.undo(); editor.history.undo();
// redo 栈顶在尾端,故正向重做倒序取项;回卷则按原数组顺序应用逆变化。
const plan = propose(); let document = plan.document;
for (const id of [...plan.history.redo].reverse()) document = applyChanges(document, plan.history.entries.get(id).changes);
assert.equal(document.elements.get('image').geometry.height, 300);
assert.equal(document.elements.get('image').geometry.x, 75);
for (const id of plan.history.redo) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
});
// Infinity尺寸拒绝;远端内容已改变使历史基准过期,同样拒绝且不改editor根。
test('invalid sizes and stale document baselines are rejected without mutation', () => {
const { editor, entry } = setup(); const root = editor.getSnapshot();
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: Infinity, height: 1 }), { code: 'invalid_creation_size' });
editor.dispatch({ type: 'element.data', target, patch: { title: 'Remote change' } }, { origin: 'remote' });
const stale = editor.getSnapshot();
assert.throws(() => planCreationSize(stale.document, stale.history, registry, entry, target, { width: 260, height: 130 }), { code: 'element_conflict' });
assert.strictEqual(editor.getSnapshot(), stale);
});
// 0.5高不满足组件尺寸约束,规划必须拒绝,不能因默认值修正绕过普通resize限制。
test('component constraints cannot be bypassed by a default size proposal', () => {
const { editor, entry } = setup(); const root = editor.getSnapshot();
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: 260, height: 0.5 }), { code: 'creation_size_constraint' });
});
// 默认宽改520、高130,redo只改高300时应保留新默认宽,避免拿旧整份geometry覆盖未编辑轴。
test('a redo edit to one size axis preserves the corrected default on the other axis', () => {
const { editor, entry } = setup();
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
editor.dispatch({ type: 'element.geometry', target, patch: { x: 75 } });
editor.history.undo(); editor.history.undo();
const root = editor.getSnapshot(), plan = planCreationSize(root.document, root.history, registry, entry, target, { width: 520, height: 130 });
assert.equal(plan.document.elements.get('image').geometry.width, 520);
assert.equal(plan.document.elements.get('image').geometry.height, 130);
let document = plan.document;
for (const id of [...plan.history.redo].reverse()) document = applyChanges(document, plan.history.entries.get(id).changes);
assert.equal(document.elements.get('image').geometry.width, 520);
assert.equal(document.elements.get('image').geometry.height, 300);
});