Resolve default image sizes atomically with creation history and recovery

This commit is contained in:
2026-11-09 00:25:18 +08:00
parent fa7dad44e7
commit 5919eb0453
20 changed files with 456 additions and 40 deletions
+32 -1
View File
@@ -11,6 +11,35 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts';
import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts';
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
async function defaultImageSizeCase(name, assert) {
const storage = createBrowserStorage({ name: `${name}-default-size`, checkpointEvery: 3 }), images = createBrowserImageDecoder();
const scope = { accountId: 'sizing-account', workspaceId: 'sizing-workspace', projectId: 'sizing-project' }, registry = createRegistry(builtinComponents());
const original = createDocument(scope.projectId, [{ id: 'root', parent: null }], []), receipts = new Map(), writes = [], queries = [], uploads = [];
let server = original, version = 1, next = 0, engine;
const persistence = {
compile(intent, id, snapshot) { return { payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: { root: snapshot.runtime.versions.get('root') } }; },
async submit(request) { writes.push(request); const { intent } = JSON.parse(request.body); server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['root', ++version]]) }); return intent.historyEffect ? { outcome: 'unresolved', blocked: true, error: 'lost_size_ack' } : { outcome: 'applied' }; },
async query(request) { queries.push(request); return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved', blocked: true }; },
canonical(request) { return receipts.get(request.id); },
};
const assets = { sourceURL: id => `/asset/${id}`, displayURL: id => `/asset/${id}`, async upload(request, bytes) { uploads.push(request);return { outcome: 'applied', canonical: { id: JSON.parse(request.body).resource_id, bytes: bytes.size, mime_type: bytes.type, width: 4, height: 2, url: '/stored' } }; }, async query() { throw Error('unexpected asset query'); }, accept: (_request, status) => status.canonical };
const options = { scope, registry, document: original, versions: new Map([['root', 1]]), storage, images, assets, persistence, createId: () => `size-${++next}` };
try {
engine = await openProjectEngine(options); const canvas = new OffscreenCanvas(4, 2); canvas.getContext('2d').fillRect(0, 0, 4, 2); const bytes = await canvas.convertToBlob({ type: 'image/png' });
const result = engine.stageFiles([{ name: 'pixel.png', bytes, defaultSize: true }], ([ref], tx) => tx.dispatch({ type: 'element.create', element: { id: 'picture', incarnation: 1, componentId: 'image', dataVersion: 1, structure: 'node', ownerCanvasId: 'root', data: { title: 'Picture', text: '', assetId: ref.id, src: ref.src }, geometry: { x: 0, y: 0, width: 260, height: 200, rotation: 0 } } }));
assert(result.status === 'applied', 'default image admission failed'); await engine.settle();
assert(engine.getSnapshot().document.elements.get('picture').geometry.height === 130 && engine.getSnapshot().history.undo.length === 1, 'decode did not enrich the original creation');
const request = writes.find(request => JSON.parse(request.body).intent.historyEffect); assert(!!request, 'size change was never submitted'); const body = request.body;
await engine.close(); const owner = await storage.claim(scope), cut = await storage.recover(owner); assert(cut.formatVersion === 6, 'size effect did not require recovery v6'); decodeRecovery(cut, registry); await owner.release();
engine = await openProjectEngine(options); await engine.settle();
assert(queries.some(query => query.id === request.id && query.body === body), 'lost size receipt did not query the frozen original request');
assert(writes.filter(write => write.id === request.id).length === 1, 'lost size receipt repeated the write');
assert(engine.editor.history.undo().status === 'applied', 'sized creation could not undo after reopening'); await engine.settle(); assert(!server.elements.has('picture'), 'undo left the server image behind');
assert(engine.editor.history.redo().status === 'applied', 'sized creation could not redo after reopening'); await engine.settle();
assert(server.elements.get('picture').incarnation === 2 && server.elements.get('picture').geometry.height === 130, 'redo lost size or reused a deleted incarnation'); assert(uploads.length === 1, 'size recovery repeated the upload');
} finally { await engine?.close(); images.close(); storage.close(); indexedDB.deleteDatabase(`${name}-default-size`); }
}
async function imageDecodeWorkerCase(assert){
const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);active++;created++;peak=Math.max(peak,active);this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
const pool=createBrowserImageDecoder();try{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),results=await Promise.all([pool.decode(bytes),pool.decode(bytes),pool.decode(bytes)]);assert(results.every(size=>size.width===4&&size.height===2),'real bitmap decoder returned incorrect dimensions');assert(peak===2&&active===0&&created===3,'image worker concurrency/cleanup failed');let code;try{await pool.decode(new Blob(['not an image'],{type:'image/png'}));}catch(error){code=error.code;}assert(code==='image_decode_failed'&&active===0,'invalid image did not fail or leaked a worker');const cancel=new AbortController(),pending=pool.decode(bytes,cancel.signal),cancelled=pending.then(()=>{throw Error('cancelled image returned dimensions');},error=>assert(error.code==='image_decode_aborted','image cancellation code changed'));cancel.abort();await cancelled;assert(active===0,'cancelled image worker leaked');}finally{pool.close();globalThis.Worker=NativeWorker;}
@@ -629,7 +658,7 @@ export async function run() {
await storage.commit(leader, { image: image(1) });
await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict');
await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap');
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 6 } }), 'unsupported_recovery_version');
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 7 } }), 'unsupported_recovery_version');
results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject');
// The first put succeeds before a later duplicate resource aborts the whole transaction.
@@ -678,6 +707,8 @@ export async function run() {
results.push('scoped paged inventory finds legacy orphans without hiding references; orphan v5 deletion rolls back faults, retries, isolates projects and forbids byte identity reuse');
await resourceRecoveryCase(name, assert);
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
await defaultImageSizeCase(name, assert);
results.push('real image Worker enriches creation history in recovery v6; IndexedDB reopening queries a lost size ACK with the original body, then undo/redo preserves size and upload identity');
await imageDecodeWorkerCase(assert);
results.push('real module image workers decode intrinsic PNG dimensions with bounded concurrency, reject bad bytes, abort safely and release their bitmap workers');
await digestWorkerCase(assert);
+1 -1
View File
@@ -24,5 +24,5 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
});
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.throws(()=>sealRecoveryImage(6,1,1,{}),/corrupt_recovery_image/);
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.throws(()=>sealRecoveryImage(7,1,1,{}),/corrupt_recovery_image/);
});
+6 -2
View File
@@ -244,8 +244,12 @@ try {
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
const pendingImageId=await evaluate(`document.querySelector('[data-headless-canvas] img[src^="blob:"]').closest('.react-flow__node').getAttribute('data-id')`);
await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'explicit fit did not use locally decoded 4:2 aspect ratio');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='200px'`,'fit undo did not restore default dimensions');assert.equal(uploads.length<=1,true);
console.log('PASS decoded image metadata is available before upload ACK; explicit ratio fit and its undo preserve the image identity');
await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'default image size did not automatically use locally decoded 4:2 aspect ratio before upload ACK');
await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');
await click('撤销');await until(`!document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')`,'one undo did not remove the automatically sized creation');
await click('重做');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')?.style.height==='130px'`,'redo lost the enriched creation dimensions');assert.equal(uploads.length<=1,true);
await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="${pendingImageId}"]');const content=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return content?.style.height==='130px';})()`,'image content retained a stale measured height after automatic sizing and redo');
console.log('PASS default image sizing precedes upload ACK; creation undo/redo includes decoded dimensions without a second history unit or upload');
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64'));
for (let i = 0; i < 100 && !uploads.length; i++) await pause(20);
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5);
+118 -2
View File
@@ -47,15 +47,131 @@ function fixture() {
open: extra => openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, assets, persistence, createId: () => `id-${++counter}`, ...extra }),
};
}
function addImage(engine, id = 'picture') {
function addImage(engine, id = 'picture', defaultSize = false) {
let resource;
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }) }], ([ref], tx) => {
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }), defaultSize }], ([ref], tx) => {
resource = ref;
tx.dispatch({ type: 'element.create', element: node(id, { assetId: ref.id, src: ref.src }) });
});
assert.equal(result.status, 'applied'); return resource;
}
test('automatic image sizing publishes with creation history across intervening edits and survives reopening', async () => {
const f = fixture(), gate = deferred(), entered = deferred(); let calls = 0;
const images = { decode: async () => { calls++; entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } };
let engine = await f.open({ images });
try {
const ref = addImage(engine, 'picture', true); await entered.promise;
const observations = [];
const unsubscribe = engine.editor.subscribe((_commit, snapshot) => { if (snapshot.document.elements.get('picture')?.geometry.height === 50) observations.push(snapshot); });
const target = { id: 'picture', incarnation: 1 };
engine.editor.dispatch({ type: 'element.geometry', target, patch: { x: 70 } });
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed before decode' } });
const undo = [...engine.getSnapshot().history.undo]; gate.resolve(); await engine.settle();
const root = engine.getSnapshot(); assert.equal(root.document.elements.get('picture').geometry.height, 50);
assert.equal(root.document.elements.get('picture').geometry.x, 70); assert.deepEqual(root.history.undo, undo);
assert.equal(root.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
assert.ok(observations.length > 0);
for (const snapshot of observations) {
assert.equal(snapshot.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
assert.equal(snapshot.history.entries.get(undo[0]).changes[0].after.geometry.height, 50);
assert.equal([...snapshot.operations.values()].some(intent => intent.historyEffect), true);
}
unsubscribe();
const effect = [...root.operations.values()].find(intent => intent.historyEffect);
assert.equal(effect.origin, 'asset-resolution'); assert.equal(effect.historyTransition.entries.length, 2);
assert.equal(f.image().formatVersion, 6); decodeRecovery(f.image(), registry);
for (const version of [1, 2, 3, 4, 5]) { const bad = structuredClone(f.image()); bad.formatVersion = version; assert.throws(() => decodeRecovery(bad, registry), /unsupported_recovery_version/); }
await engine.close(); engine = await f.open({ images });
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
assert.equal(engine.editor.history.undo().status, 'applied');
assert.equal(engine.editor.history.undo().status, 'applied');
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.x, 0);
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
assert.equal(engine.editor.history.undo().status, 'applied'); assert.equal(engine.getSnapshot().document.elements.has('picture'), false);
assert.equal(engine.editor.history.redo().status, 'applied');
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
await engine.settle(); assert.equal(calls, 1); assert.equal(f.uploads.length, 1);
} finally { gate.resolve(); await engine.close(); }
});
test('a committed user resize permanently revokes default sizing even if undone before decoding', async () => {
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
try {
const ref = addImage(engine, 'picture', true); await entered.promise;
engine.editor.dispatch({ type: 'element.geometry', target: { id: 'picture', incarnation: 1 }, patch: { height: 75 } });
assert.equal(engine.editor.history.undo().status, 'applied'); gate.resolve(); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'revoked');
assert.equal([...engine.getSnapshot().operations.values()].some(intent => intent.historyEffect), false);
decodeRecovery(f.image(), registry);
} finally { gate.resolve(); await engine.close(); }
});
test('a pending sizing grant and source bytes survive closing before decode completion', async () => {
const f = fixture(), gate = deferred(), entered = deferred(); let engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
const ref = addImage(engine, 'picture', true); await entered.promise;
const closing = engine.close(); gate.resolve(); await closing;
assert.equal(f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing.grants[0].status, 'pending');
engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) } });
try { await engine.settle(); assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); assert.equal(engine.getSnapshot().history.undo.length, 1); decodeRecovery(f.image(), registry); }
finally { await engine.close(); }
});
test('default sizing waits for a geometry gesture to finish and retains its committed movement', async () => {
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
try {
addImage(engine, 'picture', true); await entered.promise;
engine.sessions.attach('view'); assert.equal(engine.sessions.begin('view', [{ id: 'picture', incarnation: 1 }]).enabled, true);
engine.sessions.update('view', { id: 'picture', incarnation: 1 }, { x: 180 }); gate.resolve(); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
assert.equal(engine.sessions.commit('view').status, 'applied'); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.x, 180);
assert.equal(engine.getSnapshot().history.undo.length, 2);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
} finally { gate.resolve(); await engine.close(); }
});
test('replacing an image resource revokes its pending grant and a late decoder cannot change geometry', async () => {
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
try {
const ref = addImage(engine, 'picture', true); await entered.promise;
engine.editor.dispatch({ type: 'element.data', target: { id: 'picture', incarnation: 1 }, patch: { assetId: 'replacement' } });
gate.resolve(); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'revoked');
} finally { gate.resolve(); await engine.close(); }
});
test('a denied automatic resize records a recoverable block and explicit retry consumes the original grant', async () => {
const f = fixture(); let denied = true;
const engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) }, policy(command) { return command.type === 'element.geometry' && denied ? { enabled: false, code: 'forbidden', targets: ['picture'] } : { enabled: true }; } });
try {
const ref = addImage(engine, 'picture', true); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'blocked');
denied = false; await engine.retry(); await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
assert.equal(engine.getSnapshot().history.undo.length, 1); assert.equal(f.uploads.length, 1);
} finally { await engine.close(); }
});
test('future default-sizing metadata stays opaque, blocks GC and permits unrelated editing', async () => {
const f = fixture(); let engine = await f.open(); const ref = addImage(engine); await engine.settle(); await engine.close();
const future = { version: 2, opaque: { reference: 'keep' } }; f.image().formatVersion = 6; f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing = future;
engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) } });
try {
await engine.settle(); assert.equal(engine.getResourcePins().complete, false);
assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).sizing, future);
assert.equal(engine.editor.dispatch({ type: 'element.data', target: { id: 'picture', incarnation: 1 }, patch: { title: 'Edited' } }).status, 'applied');
await engine.settle(); decodeRecovery(f.image(), registry);
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
} finally { await engine.close(); }
});
test('pending images publish immediately, bytes share the durable cut, and independent canvas edits overtake upload', async () => {
const f = fixture(), gate = deferred(), upload = f.assets.upload;
f.assets.upload = async (...args) => { await gate.promise; return upload(...args); };
@@ -21,6 +21,57 @@ function fixture(original=base()){
}
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
test('pending default size effects recover with preceding creation and movement, preserving one creation undo unit', async () => {
const f = fixture(), engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized') });
const entryId = engine.getSnapshot().history.undo.at(-1);
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
const root = engine.getSnapshot();
assert.equal(engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision).status, 'applied');
await engine.settle();
const session = await engine.prepareRecovery(), plan = session.plan(choices(engine));
assert.equal(plan.history.undo.length, 2); assert.equal(plan.document.elements.get('sized').geometry.height, 40);
f.reject = false; await session.resolve(plan); await engine.settle(); decodeRecovery(f.image(), registry);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(f.server().elements.get('sized').geometry.x, 100); assert.equal(f.server().elements.get('sized').geometry.height, 40);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
assert.equal(engine.editor.history.redo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.get('sized').geometry.height, 40);
} finally { await engine.close(); }
});
test('discarding a pending size effect retains the uncorrected creation and movement histories', async () => {
const f = fixture(), engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized') }); const entryId = engine.getSnapshot().history.undo.at(-1);
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
const root = engine.getSnapshot(), result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision);
await engine.settle(); const selected = choices(engine); selected.set(result.operationIds[0], 'discard-local');
const session = await engine.prepareRecovery(), plan = session.plan(selected);
assert.equal(plan.document.elements.get('sized').geometry.height, 80); assert.equal(plan.history.undo.length, 2);
f.reject = false; await session.resolve(plan); await engine.settle();
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(f.server().elements.get('sized').geometry.height, 80);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
} finally { await engine.close(); }
});
test('a confirmed size effect is retained while an earlier independent pending edit is recovered', async () => {
const f = fixture(); f.reject = false; const engine = await f.open();
try {
engine.editor.dispatch({ type: 'element.create', element: node('sized', 'child') }); await engine.settle();
const entryId = engine.getSnapshot().history.undo.at(-1); f.reject = true;
engine.editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'Pending root title' } }); await engine.settle();
f.reject = false; const root = engine.getSnapshot();
const result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision);
await engine.settle(); assert.equal(engine.getSnapshot().runtime.ledger.operations.get(result.operationIds[0]).resolution, 'applied');
const session = await engine.prepareRecovery(), plan = session.plan(choices(engine));
await session.resolve(plan); await engine.settle(); decodeRecovery(f.image(), registry);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false);
} finally { await engine.close(); }
});
test('pending create and later edits recover with absent baselines, new keys and lifecycle-safe undo/redo',async()=>{
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));