176 lines
13 KiB
JavaScript
176 lines
13 KiB
JavaScript
/**
|
||
* 异步获知图片默认尺寸后修正创建历史的规划测试。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顺序重做,对照最终图片;只适用于不含额外初始元素的调用。 */
|
||
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();
|
||
// Undo of removal creates a new incarnation: an old asynchronous task is stale.
|
||
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();
|
||
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);
|
||
});
|