Files
Endoleap/tests/headless/core.test.mjs
T

320 lines
26 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.
/**
* 核心编辑器的组合回归测试。用内存文档与内置/自定义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');
});