Resolve default image sizes atomically with creation history and recovery
This commit is contained in:
@@ -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); };
|
||||
|
||||
Reference in New Issue
Block a user