/** * 项目引擎资源生命周期测试:本地图片先发布,文件及账本持久化后上传,依赖文档操作等待资源确认。 * 默认尺寸授权、解码元数据和显式 fitImage 各有独立语义;迟到解码不能覆盖用户调整或绑定到旧元素身份。 * 本地回收需综合 working/history/ACK/任务/显示引用,先标记、再等待宽限期,并将物理删除与恢复墓碑一同保存。 * fixture 使用内存端口,验证引擎协调及故障处理;不证明真实数据库的事务隔离或浏览器实际解码能力。 */ import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createDocument, applyChanges, builtinComponents, createRegistry } from '../../src/lib/canvas-core/index.ts'; import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts'; import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts'; // 内置 registry 提供图片资源字段与几何能力;固定作用域让重开时读取同一个项目的持久化 image。 const registry = createRegistry(builtinComponents()); const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }; const geometry = { x: 0, y: 0, width: 100, height: 100, rotation: 0 }; // 构造独立 image 元素;允许无 assetId 的基线节点 a 充当与上传无关的编辑目标。 const node = (id, data = {}) => ({ id, incarnation: 1, structure: 'node', componentId: 'image', dataVersion: 1, ownerCanvasId: 'canvas', geometry, data: { title: id, text: '', ...data } }); // 初始只有 canvas 和 a,新增 picture 可通过元素数量及服务端写次数区分,避免把恢复误当再次创建。 const original = createDocument('project', [{ id: 'canvas', parent: null }], [node('a')]); // 可手动完成的 Promise 屏障,用于停住解码、上传、文件读取或删除落盘,控制迟到结果的时序。 const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; }; // 最多轮询 100 次并让出定时器,等待特定异步阶段出现;超时直接失败,不无限等待。 async function until(predicate) { for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 1)); } assert.fail('condition did not become true'); } // 为每个用例创建文件/提交/回执表和假服务端;storage 与服务端保留状态以支持关闭后重新打开引擎。 function fixture() { let image = null, owner, counter = 0, server = original, version = 1; const files = new Map(), batches = [], uploads = [], writes = [], receipts = new Map(), assetReceipts = new Map(); const storage = { fileDeletion: 'atomic-v1', // 单 owner 领取;release 同时清空当前 owner 并将旧 handle 标为 inactive,便于引擎关闭后再开。 async claim(scope) { assert.equal(owner, undefined); const handle = { scope, fence: 1, active: true, async release() { owner = undefined; handle.active = false; } }; owner = handle; return handle; }, // 克隆先前 image,使新引擎从持久化数据重建而不是共享旧快照对象。 async recover() { return structuredClone(image); }, // 模拟 atomic-v1 文件提交:新增文件 ID 不能覆盖,删除与 image 在同一调用保存;真实回滚不由此内存实现证明。 async commit(_leader, batch) { for (const file of batch.files ?? []) { assert.equal(files.has(file.id), false); files.set(file.id, file.bytes); } for(const id of batch.deleteFiles??[])files.delete(id); image = structuredClone(batch.image); batches.push({ ...batch, image }); return { durableRootRevision: image.rootRevision }; }, // 按资源 ID 取原始 Blob;用例会替换它来制造磁盘错误、共享读取及迟到结果。 async readFile(_leader, id) { return files.get(id); }, /* 内存存储无外部句柄;owner 由 release 归还。 */ close() {}, }; const assets = { sourceURL: id => `https://assets.example/${id}`, displayURL: id => `/assets/${id}`, // 上传前断言文件已落盘、image 中已有 dispatch-marked 证据;随后保存可查询的成功资产回执。 async upload(request, bytes) { // 资源字节和 dispatch 证据都必须在上传前可恢复;这两条断言验证持久化屏障的顺序。 const payload = JSON.parse(request.body); assert.equal(await files.get(payload.resource_id).text(), await bytes.text()); assert.ok(image.payload.runtime.ledger.requests.some(([id, row]) => id === request.id && row.phase === 'dispatch-marked')); uploads.push(request); const asset = { id: payload.resource_id, url: assets.sourceURL(payload.resource_id), width: 20, height: 10, bytes: bytes.size, mime_type: bytes.type }; assetReceipts.set(request.id, asset); return { outcome: 'applied', canonical: asset }; }, // 上传回执按原 requestId 查询;存在则返回 applied 元数据,不存在仍为 unresolved。 async query(request) { return assetReceipts.has(request.id) ? { outcome: 'applied', canonical: assetReceipts.get(request.id) } : { outcome: 'unresolved' }; }, // 此 fixture 直接交出 canonical,由引擎继续验证资源身份;Foundry wire 回执校验在 adapter 专项测试中覆盖。 accept: (_request, status) => status.canonical, }; const persistence = { // 恢复读取当前假服务端文档和 canvas 版本,不能直接拿本地 working 代替 ACK。 async read(){return{document:server,versions:new Map([['canvas',version]])};}, // 简化请求把完整意图放入 payload,并读取参与 canvas 的 ACK 版本,不进行真实 wire 压缩。 compile(intent, id, snapshot) { return { payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])) }; }, // 文档写入应用 changes,递增服务端版本并按 requestId 保存 canonical 回执,供后续接纳恢复使用。 // 此替身每次 submit 都应用变化,并未实现同 ID 去重或版本拒绝;重开测试用写入次数检查客户端是否重发。 async submit(request) { const { intent } = JSON.parse(request.body); writes.push(request); server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['canvas', ++version]]) }); return { outcome: 'applied' }; }, // 文档回执查询与上传回执表分开,存在才表示该文档请求已应用。 async query(request) { return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved' }; }, // 成功文档请求取其保存的 document/versions,不凭当前本地文档猜测服务端状态。 canonical(request) { return receipts.get(request.id); }, }; return { storage, assets, persistence, files, batches, uploads, writes, assetReceipts, image: () => image, // 以相同存储和服务端端口打开新引擎;extra 可替换时钟、解码器、策略或历史容量。 open: extra => openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, assets, persistence, createId: () => `id-${++counter}`, ...extra }), }; } // stageFiles 在同一同步事务创建图片与资源工作;defaultSize 决定是否授予后续自动尺寸修正权限。 function addImage(engine, id = 'picture', defaultSize = false) { let resource; 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; } // 解码前先移动和改名,自动尺寸结果随后回写创建历史且不增加 undo 单元;重开后改名和移动可分别撤销,尺寸随创建保留,解码和上传均只一次。 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; // 订阅只记录尺寸已经生效的发布,随后核对 grant 与创建历史是否在同一 Root 中同步变化。 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' } }); // 记录解码前栈内容;完成后必须还是这几个历史 ID,尺寸补偿不能另塞一个用户撤销条目。 const undo = [...engine.getSnapshot().history.undo]; gate.resolve(); await engine.settle(); // 解码宽高比 4:2,沿用当前节点宽度 100 得到高度 50;像素元数据不会把节点直接改为 4×2。 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); // 不能先发尺寸、后补 history:任何被观察到的生效快照都要具备对应授权终态与补偿证据。 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(); // 自动尺寸是 asset-resolution 效果,关联历史证据;资源含新字段时不能把 image 格式降级为旧版本。 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(); } }); // 用户已提交 resize 即永久撤销默认尺寸授权;即使在解码完成前 undo resize,也不能重新授权自动调整。 // undo 恢复几何值,grant 则记录“用户已经接管尺寸”的事实;若一起回退 grant,迟到解码会再次抢走用户控制。 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(); } }); // 关闭时解码尚未完成,持久化保留 pending grant 和源字节;重开用新解码器继续修正,创建仍是一个历史单元。 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(); } }); // 手势预览期间暂不发布默认尺寸;提交移动后再修正高度,保留移动位置,undo 移动不会撤销尺寸补偿。 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(); } }); // 图片被换成另一资源后,旧 grant 撤销;旧解码器迟到完成不能更改当前几何。 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(); } }); // 策略拒绝自动 geometry 命令时保留 blocked 授权;显式 retry 消费原 grant,仍只有一个创建历史和一次上传。 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(); } }); // 未来版本 sizing 作为不透明数据保留,资源扫描不再声称完整;无关改名仍可保存,不能把未来授权当已知规则运行。 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(); } }); // stageFiles 立即显示元素与历史,字节和恢复状态同批保存;上传挂起时无关编辑可先同步,复制/撤销复用资源不重复上传。 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); }; const engine = await f.open(), ref = addImage(engine); assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id); assert.equal(engine.getSnapshot().history.undo.length, 1); assert.equal(engine.getSnapshot().runtime.ledger.operations.size, 2); const sequences = [...engine.getSnapshot().runtime.ledger.operations.values()].map(record => record.intent.sequence); assert.equal(new Set(sequences).size, 2); const handle = await engine.displayResource(ref.id); assert.match(handle.url, /^blob:/); handle.release(); handle.release(); engine.editor.dispatch({ type: 'element.data', target: { id: 'a', incarnation: 1 }, patch: { title: 'independent' } }); await until(() => f.writes.length === 1); assert.equal(f.uploads.length, 0); // 检查实际交给 storage 的批次,而非只看当前内存状态,证明文件与图片内容同 cut 保存。 const fileBatch = f.batches.find(batch => batch.files?.length); assert.equal(fileBatch.image.payload.document.elements.some(element => element.id === 'picture'), true); assert.equal(fileBatch.image.payload.runtime.resources.length, 1); gate.resolve(); await engine.settle(); assert.equal(f.uploads.length, 1); assert.equal(f.writes.length, 2); assert.equal(engine.getSnapshot().history.undo.length, 2); assert.ok(engine.getSnapshot().runtime.resources.get(ref.id).stored); assert.equal(engine.getSnapshot().document.elements.get('a').data.title, 'independent'); engine.editor.dispatch({ type: 'element.create', element: node('copy', { assetId: ref.id, src: ref.src }) }); await engine.settle(); assert.equal(f.uploads.length, 1); assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.uploads.length, 1); assert.equal(engine.getSnapshot().document.elements.has('copy'), false); const pins = resourcePins(engine.getSnapshot(), engine.getSnapshot().runtime.ledger, registry, new Map()); assert.equal(pins.complete, true); assert.ok([...pins.pins.get(ref.id)].some(owner => owner.startsWith('history:'))); await engine.close(); }); // 上传服务已成功但响应丢失,依赖图片文档仍等待;重开查询同一冻结 ID/body,恢复原资源后只提交一次图片文档。 test('lost upload acknowledgment recovers original byte identity and query receipt without recreating a node', async () => { const f = fixture(), upload = f.assets.upload; f.assets.upload = async (...args) => { await upload(...args); return { outcome: 'unresolved' }; }; const engine = await f.open(), ref = addImage(engine); await engine.settle(); const frozen = f.uploads[0]; assert.ok(frozen); assert.equal(f.writes.length, 0); const decoded = decodeRecovery(f.image(), registry); assert.equal(decoded.runtime.resources.get(ref.id).id, ref.id); assert.equal(decoded.operations.size, 1); assert.equal(decoded.runtime.ledger.operations.size, 2); // 破坏上传 operationId 的交叉引用应使恢复拒绝,避免把正确字节绑到另一上传工作。 const bad = structuredClone(f.image()); bad.payload.runtime.resources[0][1].operationId = 'not-the-upload'; assert.throws(() => decodeRecovery(bad, registry), /corrupt_recovery_image/); await engine.close(); // 重开时让时钟超过重试截止时间,要求先查原回执,不创建新上传请求或新节点。 const queries = [], query = f.assets.query; f.assets.query = async request => { queries.push(request); return query(request); }; const recovered = await f.open({ clock: { now: () => Date.now() + 100000, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: id => clearTimeout(id) } }); await new Promise(resolve => setTimeout(resolve, 1)); await recovered.settle(); assert.equal(queries.length, 1); assert.equal(queries[0].id, frozen.id); assert.equal(queries[0].body, frozen.body); assert.equal(f.uploads.length, 1); assert.equal(f.writes.length, 1); assert.equal(recovered.getSnapshot().document.elements.size, 2); assert.equal(recovered.getSnapshot().history.undo.length, 1); assert.ok(recovered.getSnapshot().runtime.resources.get(ref.id).stored); await recovered.close(); }); // 替换图片在本地立即可见,上传完成前服务端保留旧引用;undo/redo 和重开只切换引用,不重复上传或更换元素身份。 test('replacing an image keeps the old server reference during upload and undo/reopen/redo never uploads again',async()=>{ const f=fixture();let engine=await f.open();const original=addImage(engine);await engine.settle();const upload=f.assets.upload,gate=deferred(),entered=deferred();let replacement; f.assets.upload=async(...args)=>{entered.resolve();await gate.promise;return upload(...args);}; try{ const result=engine.stageFiles([{name:'replacement.png',bytes:new Blob(['different image bytes'],{type:'image/png'})}],([ref],tx)=>{replacement=ref;tx.dispatch({type:'element.data',target:{id:'picture',incarnation:1},patch:{assetId:ref.id,src:ref.src}});});assert.equal(result.status,'applied'); assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,replacement.id);assert.equal(engine.getSnapshot().history.undo.length,2);await entered.promise; assert.equal(f.writes.length,1);assert.equal(f.persistence.canonical(f.writes[0]).document.elements.get('picture').data.assetId,original.id); assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,original.id);gate.resolve();await engine.settle(); const last=()=>f.persistence.canonical(f.writes.at(-1)).document.elements.get('picture');assert.equal(last().data.assetId,original.id);assert.equal(last().incarnation,1);assert.equal(f.uploads.length,2);assert.equal(engine.getSnapshot().history.undo.length,1); assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(last().data.assetId,replacement.id);assert.equal(f.uploads.length,2);assert.equal(engine.getSnapshot().history.undo.length,2);assert.ok(engine.getResourcePins().pins.has(original.id)); await engine.close();engine=await f.open();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(last().data.assetId,original.id);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(last().data.assetId,replacement.id);assert.equal(f.uploads.length,2);assert.equal(last().incarnation,1); }finally{gate.resolve();await engine.close();} }); // 创建重复 ID 被拒或回调为空时,不留下文件字节、资源记录或上传任务;失败事务不能泄漏已暂存工作。 test('rejected and empty staging do not retain file bytes or create upload work', async () => { const f = fixture(), engine = await f.open(); const files = [{ name: 'image.png', bytes: new Blob(['image'], { type: 'image/png' }) }]; const result = engine.stageFiles(files, ([ref], tx) => { tx.dispatch({ type: 'element.create', element: node('a', { assetId: ref.id }) }); }); assert.equal(result.status, 'rejected'); assert.equal(engine.stageFiles(files, () => {}).operationIds.length, 0); await engine.settle(); assert.equal(f.files.size, 0); assert.equal(f.uploads.length, 0); assert.equal(engine.getSnapshot().runtime.resources.size, 0); await engine.close(); }); // 未知组件的资源 schema 使扫描不完整;明确注册的额外 owner 仍可单独保留已知资源。 test('unknown resource reference schemas prevent claiming complete garbage-collection coverage', async () => { const f = fixture(), engine = await f.open(); const opaque = { ...node('future'), componentId: 'future-image', data: { privateResource: 'unknown' } }; const doc = createDocument('project', [...original.canvases.values()], [...original.elements.values(), opaque]); const snapshot = { ...engine.getSnapshot(), document: doc }; assert.equal(resourcePins(snapshot, snapshot.runtime.ledger, registry, new Map()).complete, false); const pinned = resourcePins(engine.getSnapshot(), engine.getSnapshot().runtime.ledger, registry, new Map([['export-job', ['keep']]])); assert.deepEqual([...pinned.pins.get('keep')], ['export-job']); await engine.close(); }); // 上传回执声称成功但 assetId 错误时,保留原回执及 waiting 请求,不释放文档依赖,也不能把错误资源标为 stored。 test('invalid applied asset metadata retains the receipt and pending document without releasing the dependency', async () => { const f = fixture(), upload = f.assets.upload; f.assets.upload = async (...args) => { const result = await upload(...args); return { ...result, canonical: { ...result.canonical, id: 'different-resource' } }; }; const errors = [], engine = await f.open({ onError: error => errors.push(error) }); const ref = addImage(engine); await engine.settle(); // applied 是服务端执行事实,waiting 是本地尚未接纳:错误元数据不能把事实改回未执行,也不能解锁后续文档。 const request = f.uploads[0], stored = engine.getSnapshot().runtime.ledger.requests.get(request.id); assert.equal(stored.outcome, 'applied'); assert.equal(stored.phase, 'waiting'); assert.equal(f.writes.length, 0); assert.equal(engine.getSnapshot().runtime.receipts.get(request.id).id, 'different-resource'); assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id); assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored, undefined); assert.equal(errors[0].message, 'asset_receipt_mismatch'); await engine.close(); }); // 资源和 image 的首次持久化失败后,本地图片仍可见,但上传与文档均不发送;关闭也报错,修复存储再完成可靠保存。 test('a failed image durable cut keeps the visible element and sends neither bytes nor document', async () => { const f = fixture(), errors = [], engine = await f.open({ onError: error => errors.push(error) }); await engine.persist(); const commit = f.storage.commit; f.storage.commit = async () => { throw new Error('quota'); }; const ref = addImage(engine); await new Promise(resolve => setTimeout(resolve, 5)); await assert.rejects(() => engine.settle(), /quota/); assert.equal(f.uploads.length, 0); assert.equal(f.writes.length, 0); assert.equal(f.files.size, 0); assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id); await assert.rejects(() => engine.close(), /quota/); // 关闭失败不丢弃待保存成果;恢复原端口后再次关闭完成持久化,随后检查文件和历史。 f.storage.commit = commit; await engine.close(); assert.equal(await f.files.get(ref.id).text(), 'image bytes'); assert.equal(decodeRecovery(f.image(), registry).history.undo.length, 1); assert.ok(errors.length); }); // 服务端上传已成功、accept 暂时失败时,通过恢复会话接纳 stored 身份并释放依赖文档;history 和冻结字节不变,无二次上传。 test('known successful upload coordination recovers stored identity and releases the dependent image without uploading again',async()=>{ const f=fixture(),accept=f.assets.accept;let fail=true;f.assets.accept=(...args)=>{if(fail)throw Error('codec_temporarily_unavailable');return accept(...args);}; const engine=await f.open(),resource=addImage(engine);await until(()=>f.uploads.length===1);await engine.settle();const request=f.uploads[0],history=engine.getSnapshot().history; assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');assert.equal(f.writes.length,0);fail=false; const session=await engine.prepareRecovery();assert.deepEqual(session.acknowledgedRequestIds,[request.id]);await session.apply();await engine.settle(); assert.equal(f.uploads.length,1);assert.equal(f.writes.length,1);assert.equal(engine.getSnapshot().history,history);assert.equal(engine.getSnapshot().runtime.resources.get(resource.id).stored.id,resource.id);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body); assert.equal(await f.files.get(resource.id).text(),'image bytes');assert.equal(decodeRecovery(f.image(),registry).runtime.resources.get(resource.id).stored.id,resource.id);await engine.close(); }); // 摘要 worker 故障保留原文件和明确 blocker,不伪装成缺少字节,也不忙循环;显式 retry 恢复同一资源及创建历史。 test('digest worker failure remains distinct from missing bytes and explicit retry resumes the original resource',async()=>{ const {importSourceDigest}=await import('../../src/lib/canvas-runtime/import-integrity.ts'),f=fixture(),errors=[];let calls=0;const engine=await f.open({digests:{async digest(bytes,signal){if(++calls===1)throw Object.assign(Error('worker failed'),{code:'digest_worker_failed'});return importSourceDigest(bytes,signal);}},onError:error=>errors.push(error)}); try{const ref=addImage(engine);await engine.settle();const resource=engine.getSnapshot().runtime.resources.get(ref.id);assert.deepEqual(engine.getSnapshot().runtime.ledger.operations.get(resource.operationId).blockers,['digest_worker_failed']);assert.equal(f.files.size,1);assert.equal(f.uploads.length,0);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(calls,1,'failed worker retried in a busy loop');await engine.retry();await engine.settle();assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(errors.length,1);}finally{await engine.close();} }); // 墙上时间可手动推进,重试定时器仍用真实 setTimeout;用于精确检查回收宽限期。 const resourceClock=()=>{let now=1000;return{advance:ms=>{now+=ms;},now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};}; // 删除 picture 后编辑 a,配合 historyLimit=1 淘汰图片撤销记录,再等待 ACK,真正移除旧图片引用。 async function unreferenceImage(engine){assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'evict old image history'}}).status,'applied');await engine.settle();} // 无引用资源先保存 soft marker,再满 100ms 宽限期后把文件删除和 purge 墓碑一起保存;重开可用远端 URL,不重新上传。 // purge 只回收本地 Blob,不删除已上传资产或 stored 身份;后续显示因此能回退 displayURL。 test('local collection persists a soft marker, waits retention and atomically deletes bytes with a v3 recovery tombstone',async()=>{ const f=fixture(),clock=resourceClock();let engine=await f.open({historyLimit:1,clock});const ref=addImage(engine);try{ await engine.settle();await unreferenceImage(engine);assert.equal(engine.getResourcePins().pins.has(ref.id),false); const content=engine.getSnapshot().document,history=engine.getSnapshot().history; // 首次扫描只标记无引用时间;后续扫描必须同时满足完整覆盖和宽限期,才能删除物理文件。 const soft=await engine.collectResources({retentionMs:100});assert.deepEqual(soft.markedIds,[ref.id]);assert.deepEqual(soft.purgedIds,[]);assert.ok(f.files.has(ref.id));assert.equal(f.image().formatVersion,3);decodeRecovery(f.image(),registry); clock.advance(99);assert.deepEqual((await engine.collectResources({retentionMs:100})).purgedIds,[]);clock.advance(1); const collected=await engine.collectResources({retentionMs:100});assert.deepEqual(collected.purgedIds,[ref.id]);assert.equal(f.files.has(ref.id),false);assert.equal(engine.getSnapshot().document,content);assert.equal(engine.getSnapshot().history,history); // 用保存过的 batch 关联 deleteFiles 与 purgedAt,确保 purge 墓碑持久化时,对应文件删除也已包含在该批次中。 const deletion=f.batches.find(batch=>batch.deleteFiles?.includes(ref.id));assert.equal(deletion.image.payload.runtime.resources.find(([id])=>id===ref.id)[1].retirement.purgedAt,1100);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id); const cut=decodeRecovery(f.image(),registry);assert.equal(cut.runtime.resources.get(ref.id).retirement.purgedAt,1100); for(const version of [1,2]){const bad=structuredClone(f.image());bad.formatVersion=version;assert.throws(()=>decodeRecovery(bad,registry),/unsupported_recovery_version/);} await engine.close();engine=await f.open({historyLimit:1,clock});const handle=await engine.displayResource(ref.id);assert.equal(handle.url,f.assets.displayURL(ref.id));handle.release();assert.equal(f.uploads.length,1);assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]); }finally{await engine.close();} }); // 文档、历史、显式 pin 或未知覆盖均阻止删除;资源重新被引用时清除退休标记,之后再次无引用需重新计时。 test('document/history/ACK/explicit pins and unknown coverage prevent collection, and repinning resets the soft retention clock',async()=>{ const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);try{ await engine.settle();assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]); engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});await engine.settle();assert.ok([...engine.getResourcePins().pins.get(ref.id)].some(owner=>owner.startsWith('history:')));assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]); engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'clear history'}});await engine.settle();await engine.collectResources({retentionMs:100});clock.advance(200); assert.equal(engine.editor.dispatch({type:'element.create',element:node('again',{assetId:ref.id})}).status,'applied');assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement,undefined);await engine.settle(); engine.editor.dispatch({type:'element.remove',target:{id:'again',incarnation:1}});engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'clear again'}});await engine.settle();const next=await engine.collectResources({retentionMs:100});assert.deepEqual(next.markedIds,[ref.id]);assert.deepEqual(next.purgedIds,[]);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement.unreferencedAt,1200); engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['export-job',[ref.id]]])}));assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement,undefined);assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]); engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map()}));const root=engine.getSnapshot(),unknown=createDocument('project',[...root.document.canvases.values()],[...root.document.elements.values(),{...node('unknown'),componentId:'future'}]);assert.equal(engine.editor.reconcile(unknown,root.runtime,root.rootRevision).status,'applied');const incomplete=await engine.collectResources({retentionMs:0});assert.equal(incomplete.complete,false);assert.deepEqual(incomplete.purgedIds,[]);assert.ok(f.files.has(ref.id)); }finally{await engine.close();} }); // display 在异步磁盘读取前就取得 pin 并清除退休状态;句柄重复 release 安全,最后释放后重新启动回收宽限期。 test('a display pins before its asynchronous read and resets retirement; releasing it starts a fresh retention window',async()=>{ const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);const read=f.storage.readFile,gate=deferred(),entered=deferred();let blocked=false;try{ await engine.settle();await unreferenceImage(engine);await engine.collectResources({retentionMs:100});clock.advance(200); f.storage.readFile=async(...args)=>{if(blocked){entered.resolve();await gate.promise;}return read(...args);};blocked=true;const display=engine.displayResource(ref.id);await entered.promise; assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement,undefined);assert.ok(engine.getResourcePins().pins.get(ref.id).has('display'));assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await display;assert.match(handle.url,/^blob:/);handle.release();handle.release();assert.equal(engine.getResourcePins().pins.has(ref.id),false);assert.deepEqual((await engine.collectResources({retentionMs:100})).purgedIds,[]);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement.unreferencedAt,1200); }finally{gate.resolve();await engine.close();} }); // 两个 reader 共用一次磁盘读取及 URL;取消其中一个不释放另一个的 pin,直到最后句柄 release 才撤销 URL 并允许回收。 test('shared display reads and URLs retain byte protection until the last independent reader releases', async () => { const f = fixture(), engine = await f.open({ historyLimit: 1 }), ref = addImage(engine), gate = deferred(), entered = deferred(); const read = f.storage.readFile, createURL = URL.createObjectURL, revokeURL = URL.revokeObjectURL; let reads = 0, created = 0, revoked = 0; try { await engine.settle(); await unreferenceImage(engine); f.storage.readFile = async (...args) => { reads++; entered.resolve(); await gate.promise; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL(bytes); }; URL.revokeObjectURL = url => { revoked++; return revokeURL(url); }; // 两个调用各自持有 reader 生命周期;共享底层读取不能让一个 AbortSignal 取消另一个调用。 const cancel = new AbortController(), first = engine.displayResource(ref.id, { signal: cancel.signal }), second = engine.displayResource(ref.id); await entered.promise; assert.equal(reads, 1); const cancelled = assert.rejects(first, { code: 'display_aborted' }); cancel.abort(); await cancelled; assert.ok(engine.getResourcePins().pins.get(ref.id).has('display')); assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, []); gate.resolve(); const b = await second, c = await engine.displayResource(ref.id); assert.equal(b.url, c.url); assert.equal(created, 1); assert.equal(reads, 1); // release 幂等且按句柄计数;另一个句柄仍在时不能撤销共享 URL 或移除字节保护。 b.release(); b.release(); assert.equal(revoked, 0); assert.ok(engine.getResourcePins().pins.get(ref.id).has('display')); c.release(); assert.equal(revoked, 1); assert.equal(engine.getResourcePins().pins.has(ref.id), false); assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, [ref.id]); } finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; } }); // 关闭引擎立即拒绝挂起 display;底层文件读取后来完成也不能创建 URL 或复活显示工作。 test('project close releases a pending display immediately and never creates a URL from a late read', async () => { const f = fixture(), engine = await f.open(), ref = addImage(engine), gate = deferred(), entered = deferred(), read = f.storage.readFile, createURL = URL.createObjectURL; let created = 0; try { await engine.settle(); f.storage.readFile = async (...args) => { entered.resolve(); await gate.promise; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL(bytes); }; const pending = engine.displayResource(ref.id); await entered.promise; const rejected = assert.rejects(pending, { code: 'engine_closing' }); await engine.close(); await rejected; assert.equal(created, 0); gate.resolve(); await new Promise(resolve => setImmediate(resolve)); assert.equal(created, 0); } finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; } }); // 磁盘读取错误保留原错误码,失败 reader 释放显示 pin;恢复端口后新 reader 可重试而不再次上传。 test('display failures retain their cause and a fresh reader can retry without uploading again', async () => { const f = fixture(), engine = await f.open(), ref = addImage(engine), read = f.storage.readFile; try { await engine.settle(); f.storage.readFile = async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); }; await assert.rejects(engine.displayResource(ref.id), { code: 'disk_failed' }); assert.equal([...engine.getResourcePins().pins.get(ref.id)].includes('display'), false); f.storage.readFile = read; const handle = await engine.displayResource(ref.id); assert.match(handle.url, /^blob:/); handle.release(); assert.equal(f.uploads.length, 1); } finally { await engine.close(); } }); // 账户身份失效同时挡住新 reader 和挂起读取的迟到结果,不创建 URL,所有 display pin 均释放。 test('identity retirement fences new readers and discards a pending local read before creating its URL', async () => { const f = fixture(); let current = true; const engine = await f.open({ isCurrentIdentity: () => current }), ref = addImage(engine), read = f.storage.readFile, gate = deferred(), entered = deferred(); const createURL = URL.createObjectURL; let created = 0; try { await engine.settle(); f.storage.readFile = async (...args) => { entered.resolve(); await gate.promise; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL(bytes); }; const pending = engine.displayResource(ref.id); await entered.promise; current = false; await assert.rejects(engine.displayResource(ref.id), { code: 'identity_changed' }); const rejected = assert.rejects(pending, { code: 'identity_changed' }); gate.resolve(); await rejected; assert.equal(created, 0); assert.equal([...engine.getResourcePins().pins.get(ref.id)].includes('display'), false); } finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; } }); // 物理删除落盘期间预留目标资源,禁止再引用或 display 该资源;无关元素编辑保持可用。 test('only recollection of the reserved resource is fenced during deletion; unrelated editing stays available',async()=>{ const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),gate=deferred(),entered=deferred(),commit=f.storage.commit;try{ await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(args[1].deleteFiles?.length){entered.resolve();await gate.promise;}return commit(...args);}; const collecting=engine.collectResources({retentionMs:0});await entered.promise; assert.equal(engine.editor.dispatch({type:'element.create',element:node('late',{assetId:ref.id})}).code,'resource_collection_in_progress');await assert.rejects(engine.displayResource(ref.id),/resource_collection_in_progress/); assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'can still edit'}}).status,'applied');assert.ok(f.files.has(ref.id));gate.resolve();await collecting;assert.equal(f.files.has(ref.id),false);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'can still edit'); }finally{gate.resolve();await engine.close();} }); // 删除落盘失败后保留字节及 reservation,旧持久化 image 没有 purge 墓碑;显式 persist 成功后才删除并释放,显示回退远端 URL。 test('failed deletion keeps bytes, pending recovery cut and reservation until an explicit persistence retry succeeds',async()=>{ const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),commit=f.storage.commit;let fail=false;try{ await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk unavailable');return commit(...args);};fail=true; await assert.rejects(engine.collectResources({retentionMs:0}),/disk unavailable/);assert.ok(f.files.has(ref.id));assert.equal(f.image().payload.runtime.resources.find(([id])=>id===ref.id)[1].retirement.purgedAt,undefined);await assert.rejects(engine.displayResource(ref.id),/resource_collection_in_progress/); fail=false;await engine.persist();assert.equal(f.files.has(ref.id),false);assert.ok(engine.getSnapshot().runtime.resources.get(ref.id).retirement.purgedAt!==undefined);const display=await engine.displayResource(ref.id);assert.equal(display.url,f.assets.displayURL(ref.id));display.release(); }finally{fail=false;await engine.close();} }); // 订阅者在 purge 发布回调内同步调用 persist,也不能把 purge 墓碑先于 deleteFiles 单独保存。 test('a synchronous publication listener cannot persist a purge marker without its file deletion',async()=>{ const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine);let unsubscribe;try{ await engine.settle();await unreferenceImage(engine);unsubscribe=engine.editor.subscribe(()=>{void engine.persist().catch(()=>{});});await engine.collectResources({retentionMs:0});assert.equal(f.files.has(ref.id),false); // 即使发布监听器重入 persist,第一份携带 purgedAt 的持久化批次也必须已经包含物理删除。 const purged=f.batches.filter(batch=>batch.image.payload.runtime.resources.some(([id,resource])=>id===ref.id&&resource.retirement?.purgedAt!==undefined));assert.ok(purged.length);assert.ok(purged[0].deleteFiles.includes(ref.id),'first durable purge cut omitted physical deletion'); }finally{unsubscribe?.();await engine.close();} }); // ACK 独有内容、活跃导入、不透明 run 输出及临时 copy pin 都参与引用保留;未知组件/字段 schema 使 complete=false,不能据可见性删字节。 test('retention covers ACK-only content, opaque job envelopes and historical field schemas independently of visibility',async()=>{ const f=fixture(),engine=await f.open();try{ const root=engine.getSnapshot(),empty=createDocument('project',[...root.document.canvases.values()],[]),resource={id:'kept',operationId:'upload',name:'x',mimeType:'image/png',byteLength:1},base={...root,document:empty,runtime:{...root.runtime,resources:new Map([['kept',resource]]),acknowledged:createDocument('project',[...empty.canvases.values()],[node('remote',{assetId:'kept'})])}}; assert.ok([...resourceRetentionPins(base,registry).pins.get('kept')].some(owner=>owner.startsWith('acknowledged:'))); const plain={...base,runtime:{...base.runtime,acknowledged:empty}}; for(const status of ['preparing','running','paused']){const snapshot={...plain,runtime:{...plain.runtime,imports:new Map([['job',{id:'job',status}]])}};assert.ok(resourceRetentionPins(snapshot,registry).pins.get('kept').has('import:job'));} for(const status of ['completed','partial','cancelled'])assert.equal(resourceRetentionPins({...plain,runtime:{...plain.runtime,imports:new Map([['job',{id:'job',status}]])}},registry).pins.has('kept'),false); assert.ok(resourceRetentionPins({...plain,runtime:{...plain.runtime,runs:new Map([['run',{id:'run',status:'succeeded',outputs:[{opaque:'kept'}]}]])}},registry).pins.get('kept').has('run:run')); assert.ok(resourceRetentionPins(plain,registry,new Map(),new Map([['copy-admission',['kept']]])).pins.get('kept').has('copy-admission')); const unknown={...plain,runtime:{...plain.runtime,acknowledged:createDocument('project',[...empty.canvases.values()],[{...node('future'),componentId:'future'}])}};assert.equal(resourceRetentionPins(unknown,registry).complete,false); const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'kept',after:'next'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',changes:[field]}]])};assert.equal(resourceRetentionPins({...plain,history},registry).complete,false); const malformed={...plain,document:createDocument('project',[...empty.canvases.values()],[node('bad',{assetId:{opaque:'kept'}})])};assert.equal(resourceRetentionPins(malformed,registry).complete,false); }finally{await engine.close();} }); // 普通解码完成只发布元数据,不覆盖用户新几何;显式 fitImage 才按比例改尺寸并增加一个正常 undo 单元。 test('late image metadata never changes edited geometry; explicit fitting creates one normal undo unit',async()=>{ const f=fixture(),gate=deferred(),entered=deferred();const images={decode:async()=>{entered.resolve();await gate.promise;return{width:400,height:200};}},engine=await f.open({images}),ref=addImage(engine);try{await entered.promise;assert.equal(engine.editor.dispatch({type:'element.geometry',target:{id:'picture',incarnation:1},patch:{width:300,height:70}}).status,'applied');gate.resolve();await engine.settle();assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,{version:1,status:'ready',width:400,height:200,source:'decode'});assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.getSnapshot().history.undo.length,2);assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);assert.equal(engine.getSnapshot().history.undo.length,3);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.editor.history.redo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);await engine.settle();decodeRecovery(f.image(),registry);assert.equal(f.uploads.length,1); }finally{gate.resolve();await engine.close();} }); // 解码失败不阻塞资源上传;retry 只重新解码并保持资源、元素、上传及历史身份。 test('image decode failure does not block upload and retry keeps resource, element and upload identities',async()=>{ const f=fixture();let fail=true,calls=0;const engine=await f.open({images:{decode:async()=>{calls++;if(fail)throw Error('bad decoder');return{width:40,height:20};}}}),ref=addImage(engine);try{await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'failed');assert.equal(f.uploads.length,1);fail=false;await engine.retry();await engine.settle();assert.equal(calls,2);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{await engine.close();} }); // 资源元数据可跨元素删除/恢复继续使用,但 fitImage 必须匹配当前 incarnation 与 assetId,旧身份或替代资源都拒绝。 test('decoder metadata can cross deletion/restoration, but fitting cannot target a stale incarnation or replaced resource',async()=>{ const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:20,height:10};}}}),ref=addImage(engine);try{await entered.promise;engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});engine.editor.history.undo();const restored=engine.getSnapshot().document.elements.get('picture');assert.equal(restored.incarnation,2);gate.resolve();await engine.settle();assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},'another-resource').code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},ref.id).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,50);}finally{gate.resolve();await engine.close();} }); // 恢复预览 hold 期间解码虽完成也不能发布新状态根;取消会话释放协调边界后才接纳 ready 元数据。 test('recovery holds defer image completion metadata until the coordinated root is released',async()=>{ const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:10,height:5};}}}),ref=addImage(engine);try{await entered.promise;const recovery=await engine.prepareRecovery(),revision=engine.getSnapshot().rootRevision;gate.resolve();await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'pending');await recovery.cancel();await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');}finally{gate.resolve();await engine.close();} }); // 未来版本 image 元数据不触发重解码或静默迁移,原样持久化并阻止完整 GC 声明,仍允许无关编辑。 test('future image metadata remains opaque without blocking unrelated editing or being silently reinterpreted',async()=>{ const f=fixture();let engine=await f.open();const ref=addImage(engine);try{await engine.settle();await engine.close();const future={version:2,status:'future',payload:{retain:true}};f.image().payload.runtime.resources.find(([id])=>id===ref.id)[1].image=future;let calls=0;engine=await f.open({images:{decode:async()=>{calls++;return{width:1,height:1};}}});await engine.settle();assert.equal(calls,0);assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,future);assert.equal(engine.getResourcePins().complete,false);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'can edit'}}).status,'applied');await engine.settle();assert.deepEqual(decodeRecovery(f.image(),registry).runtime.resources.get(ref.id).image,future);assert.equal(f.uploads.length,1);}finally{await engine.close();} });