docs(tests): explain resource sizing and run control lifecycle

This commit is contained in:
2026-11-12 18:20:31 +08:00
parent 48ef0dc896
commit 7ac78e1191
2 changed files with 10 additions and 2 deletions
+4
View File
@@ -62,6 +62,7 @@ function fixture() {
// 简化请求把完整意图放入 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' }; },
@@ -98,6 +99,7 @@ test('automatic image sizing publishes with creation history across intervening
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');
@@ -128,6 +130,7 @@ test('automatic image sizing publishes with creation history across intervening
});
// 用户已提交 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 {
@@ -361,6 +364,7 @@ const resourceClock=()=>{let now=1000;return{advance:ms=>{now+=ms;},now:()=>now,
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);
+6 -2
View File
@@ -137,6 +137,7 @@ test('AI admission orders prior and later edits, persists its job, and does not
await f.clock.advance(1);await engine.settle();
const finished=engine.getSnapshot();assert.equal(finished.runtime.runs.get(run.id).status,'succeeded');assert.equal(finished.runtime.runs.get(run.id).cursor,4);
assert.equal(finished.document.elements.get('a').data.text,'generated');assert.equal(finished.document.elements.get('a').geometry.x,200);assert.equal(finished.document.elements.get('future').data.opaque.keep,true);assert.equal(finished.history.undo.length,2);
// 两次用户编辑有可撤销的内容意图;run.start 另占账本操作。自动结果由事件协调接纳,不作为第三次用户编辑压栈。
assert.equal(finished.operations.size,2);assert.equal(finished.runtime.ledger.operations.size,3);
const restored=decodeRecovery(f.storage.image(),registry);assert.equal(restored.runtime.runs.get(run.id).cursor,4);assert.equal(restored.runtime.runs.get(run.id).outputEvents[0].output.text,'generated');
await engine.close();
@@ -169,7 +170,7 @@ test('run event waits for outstanding receipts and retains a content conflict, o
test('older run event cannot rewind a later canonical document receipt',async()=>{
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish();
engine.editor.dispatch({type:'element.geometry',target,patch:{x:500}});await engine.settle();
// The server rejects its stale version first. Adopt an externally refreshed full base only after receipt resolution.
// finish 已推进服务端版本,旧版本的几何请求先取得明确拒绝;再接纳完整基线并重放本地几何,旧事件不能覆盖它。
const server=f.server();const refreshed=engine.refresh(server.document,server.versions);assert.equal(refreshed.status,'applied');
await f.clock.advance(0);await engine.settle();
assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,500);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'generated');
@@ -185,7 +186,8 @@ test('run durability failure prevents admission and close fences late poll respo
assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'original');
});
// 读、写和轮询共用容量为二的网络门;排队请求被 abort 后退出,剩余请求完成时活跃数归零。
// 直接测试容量为二的网络门:排队请求被 abort 后退出,剩余请求完成时活跃数归零。
// execute 是统一模拟工作,本例未装配 HTTP transport;它验证共用门的并发/取消机制,不单独证明每类网络入口都已接入。
test('transport gate bounds combined run/read/write capacity and drops aborted waiters',async()=>{
const gate=createNetworkGate(2),release=defer(),controller=new AbortController();let active=0,max=0;
const execute=signal=>gate.run(signal,async()=>{max=Math.max(max,++active);await release.promise;active--;});
@@ -245,6 +247,7 @@ test('durable cancellation survives response loss and does not create content hi
});
// 显式应用立即形成可撤销内容修改;结果未知时撤销仍保留,恢复后先确认原应用再同步逆向编辑,redo 不重新生成或重复 apply。
// history 保留的是选定输出造成的字段前后值;redo 走普通文档同步,不能再次调用可能产生新副作用的 run.start/run.apply。
test('explicit output application is immediate and undoable while unknown; recovery and redo never regenerate',async()=>{
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish('chosen output',true);await f.clock.advance(0);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];assert.equal(run.status,'conflict');
f.loseApply=true;const applied=engine.applyRunOutput(run.id,target,id(999));assert.equal(applied.status,'applied');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'chosen output');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().operations.get(applied.operationIds[0]).operationType,'run.apply');await engine.settle();
@@ -255,6 +258,7 @@ test('explicit output application is immediate and undoable while unknown; recov
// 取消确认推进 controlEpoch;此前捕获的旧轮询不能回退 cancelled,也不能提前推进游标,下一轮新响应才消费取消事件。
// controlEpoch 是控制状态的换代屏障,cursor 是已接纳事件序号;取消已确认时 cursor 仍可为 0,两者不应合成一个计数。
test('a poll captured before cancellation cannot regress the acknowledged control state',async()=>{
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];f.pollGate=defer();await f.clock.advance(0);await tick();
engine.cancelRun(run.id);await tick();assert.equal(engine.getSnapshot().runtime.runs.get(run.id).status,'cancelled');assert.equal(engine.getSnapshot().runtime.runs.get(run.id).controlEpoch,1);