320 lines
26 KiB
JavaScript
320 lines
26 KiB
JavaScript
/**
|
||
* 核心编辑器的组合回归测试。用内存文档与内置/自定义registry验证事务发布、history、组件兼容和临时手势,不连接服务端。
|
||
* 多数失败断言同时检查根对象身份,确保“拒绝”不仅返回错误,还没有发布中间文档/历史/运行元数据。
|
||
*/
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { createDocument, createEditor, createRegistry, builtinComponents, defineComponent, defineFeature, editable, movable, resizable, rotatable, migrateElement, commonCanvas } from '../../src/lib/canvas-core/index.ts';
|
||
|
||
/** 共用合法矩形几何,节点构造器复用该输入;createDocument/editor负责取得自己的不可变数据。 */
|
||
const geometry = { x: 10, y: 20, width: 100, height: 80, rotation: 0 };
|
||
/** 默认text节点在root,可替换owner/component构造子图或未知类型。 */
|
||
const node = (id, ownerCanvasId = 'root', componentId = 'text') => ({ id, incarnation: 1, ownerCanvasId, componentId, dataVersion: 1, structure: 'node', geometry, data: { title: id, text: '' } });
|
||
/** 固定第一生命周期目标;删除恢复后继续使用它应被拒绝。 */
|
||
const target = id => ({ id, incarnation: 1 });
|
||
/** 每个用例隔离editor并使用确定性操作ID;options可覆盖registry/document/policy等依赖。 */
|
||
function setup(elements = [node('a'), node('b')], options = {}) {
|
||
let next = 0;
|
||
return createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], elements), registry: createRegistry(builtinComponents()), createId: () => `op-${++next}`, ...options });
|
||
}
|
||
|
||
// 让reduceRuntime在特定标题下抛错,检查根完全不变;正常提交后区分rootRevision与文档revision,旧修订号更新须拒绝。
|
||
test('runtime preparation shares the document publication boundary; metadata has a separate root revision', () => {
|
||
const editor = setup(undefined, { runtime: Object.freeze({ ids: [] }), reduceRuntime(previous, next, commit) {
|
||
if (next.document.elements.get('a').data.title === 'reject-ledger') throw new Error('ledger_failed');
|
||
return Object.freeze({ ids: Object.freeze([...previous.ids, ...commit.operationIds]) });
|
||
} });
|
||
const initial = editor.getSnapshot();
|
||
assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'reject-ledger' } }).status, 'rejected');
|
||
assert.strictEqual(editor.getSnapshot(), initial);
|
||
const result = editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'committed' } });
|
||
assert.deepEqual(editor.getSnapshot().runtime.ids, result.operationIds);
|
||
assert.equal(editor.getSnapshot().rootRevision, 1); assert.equal(editor.getSnapshot().revision, 1);
|
||
const old = editor.getSnapshot();
|
||
assert.equal(editor.updateRuntime(value => ({ ...value, ready: true }), old.rootRevision).status, 'applied');
|
||
assert.equal(editor.getSnapshot().rootRevision, 2); assert.equal(editor.getSnapshot().revision, 1);
|
||
assert.equal(editor.updateRuntime(value => value, old.rootRevision).code, 'stale_plan');
|
||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'canonical' } }, { origin: 'ack' });
|
||
assert.equal(editor.getSnapshot().operations.size, 1); assert.equal(editor.getSnapshot().history.undo.length, 1);
|
||
});
|
||
|
||
// 事务首条合法、第二条目标缺失:订阅计数与根身份共同证明没有泄露首条命令的部分结果。
|
||
test('a rejected second command publishes no document, history, operation or notification', () => {
|
||
const editor = setup(), before = editor.getSnapshot(); let notifications = 0;
|
||
editor.subscribe(() => notifications++);
|
||
const result = editor.transaction(tx => {
|
||
tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'changed' } });
|
||
tx.dispatch({ type: 'element.geometry', target: target('missing'), patch: { x: 42 } });
|
||
});
|
||
assert.equal(result.status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); assert.equal(notifications, 0);
|
||
});
|
||
|
||
// frame允许移动而不允许旋转;can只查能力,不消耗createId,也不发布根。
|
||
test('can is pure and command handlers come from the component capability table', () => {
|
||
let ids = 0;
|
||
const frame = node('a', 'root', 'frame'), editor = setup([frame], { createId: () => String(++ids) });
|
||
const before = editor.getSnapshot();
|
||
assert.equal(editor.can({ type: 'element.geometry', target: target('a'), patch: { rotation: 30 } }).enabled, false);
|
||
assert.equal(editor.can({ type: 'element.geometry', target: target('a'), patch: { x: 30 } }).enabled, true);
|
||
assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before);
|
||
});
|
||
|
||
// 跨父子端点的边归root,操作覆盖两canvas;带函数的非JSON元素包络必须拒绝,不能序列化时悄悄丢字段。
|
||
test('connection creation guards endpoint canvases and non-JSON envelopes reject without silent truncation',()=>{
|
||
const document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[node('a'),node('f','root','frame'),node('b','child')]);
|
||
const editor=createEditor({registry:createRegistry(builtinComponents()),document});
|
||
const edge={id:'e',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null};
|
||
const result=editor.dispatch({type:'element.create',element:edge});assert.equal(result.status,'applied');assert.deepEqual(editor.getSnapshot().operations.get(result.operationIds[0]).canvasIds,['child','root']);
|
||
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.create',element:{...node('invalid'),unserializable:()=>42}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);
|
||
});
|
||
|
||
// 未知组件的constructor/__proto__原样保留,但对可编辑对象注入危险字段被拒绝,并核查原型未污染。
|
||
test('opaque JSON keys survive unrelated edits while unsafe field setters remain rejected',()=>{
|
||
const future={...node('opaque'),componentId:'future.plugin',dataVersion:99,data:JSON.parse('{"constructor":{"retain":true},"__proto__":{"ordinary":"json data"}}')};
|
||
const editor=setup([node('a'),future]);editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});
|
||
assert.deepEqual(editor.getSnapshot().document.elements.get('opaque').data,future.data);
|
||
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:JSON.parse('{"__proto__":{"polluted":true}}')}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(Object.prototype.polluted,undefined);
|
||
});
|
||
|
||
// 先写null再undo/redo检查字段存在性,随后删除选中节点并dispose会话,验证选择/视图清理。
|
||
test('absent and explicit null are distinct history values; view selection follows element deletion', async () => {
|
||
const editor = setup();
|
||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } });
|
||
assert.equal(Object.hasOwn(editor.getSnapshot().document.elements.get('a').data, 'optional'), true);
|
||
editor.history.undo(); assert.equal(Object.hasOwn(editor.getSnapshot().document.elements.get('a').data, 'optional'), false);
|
||
editor.history.redo(); assert.equal(editor.getSnapshot().document.elements.get('a').data.optional, null);
|
||
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
||
const sessions = createSessions(editor); sessions.attach('view'); sessions.select('view', ['a']);
|
||
editor.dispatch({ type: 'element.remove', target: target('a') });
|
||
assert.equal(sessions.getSnapshot().views.get('view').selection.size, 0); sessions.dispose();
|
||
assert.equal(sessions.getSnapshot().views.size, 0);
|
||
});
|
||
|
||
// 动态policy先允许预览,再通过runtime发布触发权限重查;取消原因和下一次begin都必须反映forbidden。
|
||
test('permission changes cancel geometry previews and prevent new gestures', async () => {
|
||
let permitted = true;
|
||
const editor = setup(undefined, { policy: () => permitted ? { enabled: true } : { enabled: false, code: 'forbidden', targets: ['a'] } });
|
||
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
||
const sessions = createSessions(editor); sessions.attach('view');
|
||
assert.equal(sessions.begin('view', [target('a')]).enabled, true);
|
||
sessions.update('view', target('a'), { x: 100 }); permitted = false;
|
||
editor.updateRuntime(() => ({ permissionEpoch: 2 }));
|
||
assert.equal(sessions.getSnapshot().gesture, null); assert.equal(sessions.getSnapshot().lastCancelReason, 'permission_changed');
|
||
assert.equal(sessions.begin('view', [target('a')]).code, 'forbidden'); sessions.dispose();
|
||
});
|
||
|
||
// 把标题和位置放进一个事务,再注入不记历史的资源ACK;undo/redo只能改原编辑字段,不能抹掉资源结果。
|
||
test('several commands are one history unit; data undo preserves a later resource acknowledgment', () => {
|
||
const editor = setup();
|
||
editor.transaction(tx => {
|
||
tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'changed' } });
|
||
tx.dispatch({ type: 'element.geometry', target: target('a'), patch: { x: 30 } });
|
||
});
|
||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { asset: 'stored-asset' } }, { history: false, origin: 'asset-resolution' });
|
||
assert.equal(editor.getSnapshot().history.undo.length, 1);
|
||
assert.equal(editor.history.undo().status, 'applied');
|
||
const value = editor.getSnapshot().document.elements.get('a');
|
||
assert.equal(value.data.title, 'a'); assert.equal(value.geometry.x, 10); assert.equal(value.data.asset, 'stored-asset');
|
||
assert.equal(editor.history.redo().status, 'applied');
|
||
assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored-asset');
|
||
});
|
||
|
||
// 远端后来改同一标题,撤销本地旧值必须拒绝,并保持当前根和栈不变。
|
||
test('same-field conflict does not pop history or overwrite remote edits', () => {
|
||
const editor = setup();
|
||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'local' } });
|
||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'remote' } }, { history: false, origin: 'remote' });
|
||
const before = editor.getSnapshot();
|
||
assert.equal(editor.history.undo().status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before);
|
||
});
|
||
|
||
// 删除连带移除边,恢复带回边且incarnation递增;旧目标命令不能修改恢复后的新生命周期。
|
||
test('delete undo restores a new incarnation and the original connections; stale targets remain invalid', () => {
|
||
const edge = { id: 'e', incarnation: 1, ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, structure: 'connection', source: { elementId: 'a' }, target: { elementId: 'b' }, path: null };
|
||
const editor = setup([node('a'), node('b'), edge]);
|
||
assert.equal(editor.dispatch({ type: 'element.remove', target: target('a') }).status, 'applied');
|
||
assert.equal(editor.getSnapshot().document.elements.has('e'), false);
|
||
assert.equal(editor.history.undo().status, 'applied');
|
||
assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 2);
|
||
assert.equal(editor.getSnapshot().document.elements.has('e'), true);
|
||
assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'late result' } }).status, 'rejected');
|
||
assert.equal(editor.history.redo().status, 'applied');
|
||
assert.equal(editor.history.undo().status, 'applied');
|
||
assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 3);
|
||
});
|
||
|
||
// historyLimit=2检查淘汰;撤销后新分支清redo,同值编辑保持根身份,避免空操作占历史。
|
||
test('new user edit clears redo, bounded history evicts old entries, no-op creates no operation', () => {
|
||
const editor = setup(undefined, { historyLimit: 2 });
|
||
for (const title of ['one', 'two', 'three']) editor.dispatch({ type: 'element.data', target: target('a'), patch: { title } });
|
||
assert.equal(editor.getSnapshot().history.undo.length, 2); assert.equal(editor.getSnapshot().history.entries.size, 2);
|
||
editor.history.undo();
|
||
editor.dispatch({ type: 'element.data', target: target('b'), patch: { title: 'new branch' } });
|
||
assert.equal(editor.getSnapshot().history.redo.length, 0);
|
||
const before = editor.getSnapshot();
|
||
editor.dispatch({ type: 'element.data', target: target('b'), patch: { title: 'new branch' } });
|
||
assert.strictEqual(editor.getSnapshot(), before);
|
||
});
|
||
|
||
// 异步事务在await前后都有写入尝试;等待微任务后根仍不变,捕获的tx也已关闭。
|
||
test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => {
|
||
const editor = setup(), before = editor.getSnapshot(); let captured;
|
||
const result = editor.transaction(async tx => {
|
||
captured = tx;
|
||
tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'before await' } });
|
||
await Promise.resolve();
|
||
tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'after await' } });
|
||
});
|
||
assert.equal(result.status, 'rejected'); await Promise.resolve(); await Promise.resolve();
|
||
assert.strictEqual(editor.getSnapshot(), before);
|
||
assert.throws(() => captured.dispatch({ type: 'element.remove', target: target('a') }), /transaction_closed/);
|
||
});
|
||
|
||
// 编辑已知节点不改未知记录;未知组件自身无法借用未安装行为写入。
|
||
test('unknown and future components survive known-object editing but cannot use uninstalled behavior', () => {
|
||
const unknown = { ...node('future', 'root', 'future/audio'), dataVersion: 99, data: { novel: ['keep', { every: 'field' }] } };
|
||
const editor = setup([node('a'), unknown]);
|
||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'edited' } });
|
||
assert.deepEqual(editor.getSnapshot().document.elements.get('future'), unknown);
|
||
assert.equal(editor.dispatch({ type: 'element.data', target: target('future'), patch: { title: 'overwrite' } }).status, 'rejected');
|
||
});
|
||
|
||
// 重复feature在registry装配时报错;自定义aspect缩放仅改width也应计算相应height。
|
||
test('capability conflicts are configuration errors; custom resize handlers can use a different constraint', () => {
|
||
assert.throws(() => createRegistry([defineComponent({ id: 'bad', dataVersion: 1, structure: 'node', validate: () => true, features: [movable(), movable()] })]), /duplicate_feature/);
|
||
const registry = createRegistry([defineComponent({ id: 'ratio', dataVersion: 1, structure: 'node', validate: () => true, features: [resizable({ aspect: true })] })]);
|
||
const editor = setup([{ ...node('a', 'root', 'ratio'), geometry: { ...geometry, width: 200, height: 100 } }], { registry });
|
||
assert.equal(editor.dispatch({ type: 'element.geometry', target: target('a'), patch: { width: 400 } }).status, 'applied');
|
||
assert.equal(editor.getSnapshot().document.elements.get('a').geometry.height, 200);
|
||
});
|
||
|
||
// 从v1 title迁为v2 heading,身份/owner稳定且原对象版本仍为1,证明迁移处理副本。
|
||
test('migration validates a staged copy and retains stable identity and ownership', () => {
|
||
const registry = createRegistry([defineComponent({ id: 'text', dataVersion: 2, structure: 'node', features: [editable()], validate: data => typeof data.heading === 'string', migrations: { 1: data => ({ heading: data.title }) } })]);
|
||
const original = node('a');
|
||
const upgraded = migrateElement(original, registry);
|
||
assert.equal(upgraded.id, original.id); assert.equal(upgraded.ownerCanvasId, original.ownerCanvasId); assert.equal(upgraded.dataVersion, 2);
|
||
assert.equal(original.dataVersion, 1);
|
||
});
|
||
|
||
// 分别构造重复component与不同feature抢同能力,确认配置拒绝;成功装配表也不暴露set。
|
||
test('distinct features cannot install the same capability or overwrite an existing component',()=>{
|
||
const handler=()=>[],a=defineFeature('first',{'content.edit':handler}),b=defineFeature('second',{'content.edit':handler});
|
||
const definition=defineComponent({id:'plugin',dataVersion:1,structure:'node',validate:()=>true,features:[a]});
|
||
assert.throws(()=>createRegistry([definition,definition]),/duplicate_component/);
|
||
assert.throws(()=>createRegistry([{...definition,features:[a,b]}]),/duplicate_capability/);
|
||
const registry=createRegistry([definition]);assert.equal(registry.handlers.get('plugin').get('content.edit'),handler);
|
||
assert.equal(registry.components.set,undefined);assert.equal(registry.handlers.get('plugin').set,undefined);
|
||
});
|
||
|
||
// 遍历缺步骤、抛错、修改冻结输入和非法JSON输出;检查原始值、根、索引及通知全未变,并覆盖未来版本透传与getter只读一次。
|
||
test('failed schema or reference migrations retain the original data, version and identity without partial publication',()=>{
|
||
const original={...node('a'),data:{title:'Original',links:['b'],nested:{retain:true}}},saved=structuredClone(original);
|
||
for(const migrations of[
|
||
{},
|
||
{1:()=>{throw Error('reference_mapping_failed');}},
|
||
{1:data=>{data.links.push('unexpected');return{heading:'Changed'};}},
|
||
{1:()=>({heading:3})},
|
||
{1:()=>({heading:'Valid',invalid:undefined})},
|
||
{1:()=>({heading:'Valid',invalid:()=>42})},
|
||
{1:()=>({heading:'Valid',invalid:NaN})},
|
||
]){
|
||
const registry=createRegistry([defineComponent({id:'text',dataVersion:2,structure:'node',features:[editable()],validate:data=>typeof data.heading==='string',migrations})]);
|
||
const editor=setup([node('safe')]),before=editor.getSnapshot(),queries=editor.getQueries();let notifications=0;editor.subscribe(()=>notifications++);
|
||
const result=editor.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:'safe',incarnation:1},patch:{title:'must roll back'}});migrateElement(original,registry);});
|
||
assert.equal(result.status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.strictEqual(editor.getQueries(),queries);assert.equal(notifications,0);assert.deepEqual(original,saved);
|
||
}
|
||
const future={...original,dataVersion:99},registry=createRegistry(builtinComponents());assert.strictEqual(migrateElement(future,registry),future);assert.deepEqual(original,saved);
|
||
const multi=createRegistry([defineComponent({id:'text',dataVersion:3,structure:'node',features:[],validate:()=>true,migrations:{1:data=>({...data,mapped:'b-prime'}),2:()=>{throw Error('second_reference_mapping_failed');}}})]);
|
||
assert.throws(()=>migrateElement(original,multi),/second_reference_mapping_failed/);assert.deepEqual(original,saved);
|
||
for(const data of[null,[],{title:'Invalid',lost:undefined}])assert.throws(()=>migrateElement({...original,data},createRegistry(builtinComponents())),/invalid_migration/);
|
||
let reads=0;const captured=createRegistry([defineComponent({id:'text',dataVersion:2,structure:'node',features:[],validate:data=>data.heading==='Captured once',migrations:{1:()=>({get heading(){return ++reads===1?'Captured once':undefined;}})}})]);
|
||
const migrated=migrateElement(original,captured);assert.equal(reads,1);assert.equal(migrated.data.heading,'Captured once');assert.deepEqual(original,saved);assert.ok(Object.isFrozen(migrated.data));
|
||
});
|
||
|
||
// 事务内部尝试dispatch/transaction/undo/runtime/reconcile,全部重入拒绝;外层空事务也不得触发reduceRuntime或通知。
|
||
test('reentrant mutation entry points reject without publishing document, history, runtime, indexes or operations',()=>{
|
||
for(const attempt of[
|
||
editor=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'nested'}}),
|
||
editor=>editor.transaction(tx=>tx.dispatch({type:'element.remove',target:{id:'a',incarnation:1}})),
|
||
editor=>editor.history.undo(),
|
||
editor=>editor.updateRuntime(()=>({published:true})),
|
||
editor=>editor.reconcile(editor.getSnapshot().document,{published:true},editor.getSnapshot().rootRevision),
|
||
]){
|
||
let reductions=0,notifications=0;const editor=setup(undefined,{runtime:{published:false},reduceRuntime(previous){reductions++;return previous;}}),before=editor.getSnapshot(),queries=editor.getQueries();editor.subscribe(()=>notifications++);
|
||
const result=editor.transaction(()=>{const nested=attempt(editor);assert.equal(nested.code,'reentrant_dispatch');assert.strictEqual(editor.getSnapshot(),before);});
|
||
assert.equal(result.status,'applied');assert.deepEqual(result.operationIds,[]);assert.strictEqual(editor.getSnapshot(),before);assert.strictEqual(editor.getQueries(),queries);assert.equal(reductions,0);assert.equal(notifications,0);assert.equal(before.operations.size,0);assert.equal(before.history.undo.length,0);
|
||
}
|
||
});
|
||
|
||
// 两兄弟canvas共同上级为root;把root挂到其后代会成环,须拒绝且保持根不变。
|
||
test('cross-child connection belongs to the common ancestor; cyclic canvas ownership rejects atomically', () => {
|
||
const elements = [node('f'), node('g'), node('a', 'child'), node('b', 'sibling')];
|
||
const canvases = [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'f' } }, { id: 'sibling', parent: { canvasId: 'root', entryElementId: 'g' } }];
|
||
const registry = createRegistry(builtinComponents());
|
||
const document = createDocument('p', canvases, elements);
|
||
assert.equal(commonCanvas(document, 'child', 'sibling'), 'root');
|
||
const editor = setup(elements, { registry, document }); const before = editor.getSnapshot();
|
||
assert.equal(editor.dispatch({ type: 'canvas.reparent', canvasId: 'root', parent: { canvasId: 'child', entryElementId: 'a' } }).status, 'rejected');
|
||
assert.strictEqual(editor.getSnapshot(), before);
|
||
});
|
||
|
||
// 快照集合无set,订阅者抛错也不能回滚已发布的合法提交;观察通知与提交成功边界分开。
|
||
test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => {
|
||
const editor = setup();
|
||
assert.equal(editor.getSnapshot().document.elements.set, undefined);
|
||
editor.subscribe(() => { throw new Error('observer'); });
|
||
assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'committed' } }).status, 'applied');
|
||
assert.equal(editor.getSnapshot().document.elements.get('a').data.title, 'committed');
|
||
});
|
||
|
||
// 连续预览不改根;期间资源ACK正常合入,取消只撤预览。第二场手势提交才产生一个history条目。
|
||
test('gesture preview is transient, cancel preserves an upload ACK, commit creates one history entry', async () => {
|
||
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
||
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
|
||
const initial = editor.getSnapshot();
|
||
assert.equal(sessions.begin('view', [target('a')]).enabled, true);
|
||
sessions.update('view', target('a'), { x: 30 }); sessions.update('view', target('a'), { x: 60 });
|
||
assert.strictEqual(editor.getSnapshot(), initial); assert.equal(sessions.effectiveGeometry('a').x, 60);
|
||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { asset: 'stored' } }, { origin: 'asset-resolution', history: false });
|
||
assert.equal(sessions.getSnapshot().gesture.phase, 'active');
|
||
sessions.cancel('escape'); assert.equal(sessions.effectiveGeometry('a').x, 10);
|
||
assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored');
|
||
sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 90 });
|
||
assert.equal(sessions.commit('view').status, 'applied'); assert.equal(editor.getSnapshot().history.undo.length, 1);
|
||
assert.equal(editor.getSnapshot().document.elements.get('a').geometry.x, 90);
|
||
detach(); sessions.dispose();
|
||
});
|
||
|
||
// 远端几何覆盖导致预览取消;之后detach再次取消手势,文档保持远端已提交位置。
|
||
test('geometry conflict and view detach cancel only that gesture, without deleting model content', async () => {
|
||
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
||
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
|
||
sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 30 });
|
||
editor.dispatch({ type: 'element.geometry', target: target('a'), patch: { x: 70 } }, { origin: 'remote', history: false });
|
||
assert.equal(sessions.getSnapshot().gesture, null); assert.equal(sessions.effectiveGeometry('a').x, 70);
|
||
sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 100 }); detach();
|
||
assert.equal(sessions.getSnapshot().gesture, null); assert.equal(editor.getSnapshot().document.elements.get('a').geometry.x, 70);
|
||
sessions.dispose();
|
||
});
|
||
|
||
// prepareIntent伪造操作ID并清changes,必须在发布前拒绝,不允许传输准备重写业务事实。
|
||
test('transport preparation cannot rewrite a committed document change or its identity',()=>{
|
||
const editor=setup(undefined,{prepareIntent:intent=>({...intent,id:'another-operation',changes:[]})});
|
||
const before=editor.getSnapshot(),result=editor.dispatch({type:'element.data',target:target('a'),patch:{text:'must not publish'}});
|
||
assert.equal(result.code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
|
||
});
|
||
|
||
// 300次同组修改形成大历史,假时钟迫使编码让步;编码期间再编辑,旧镜像仍只含捕获的300项,当前undo仍正确。
|
||
test('growing grouped history can yield during recovery encoding without altering undo data',async()=>{
|
||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||
const editor=setup();
|
||
for(let i=0;i<300;i++)assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{title:'step-'+i}},{groupId:'large-history'}).status,'applied');
|
||
const entry=editor.getSnapshot().history.entries.get(editor.getSnapshot().history.undo.at(-1));
|
||
assert.equal(entry.changes.length,300);let tick=0,yields=0;
|
||
const pending=sealRecoveryImageAsync(17,1,1,entry,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
|
||
assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{title:'later'}},{groupId:'large-history'}).status,'applied');
|
||
const image=await pending;assert.ok(yields>0);assert.deepEqual(image.payload,JSON.parse(JSON.stringify(entry)));assert.equal(image.payload.changes.length,300);
|
||
assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'a');
|
||
});
|