diff --git a/tests/headless/elision.test.mjs b/tests/headless/elision.test.mjs index 6aaa813..ab6a8af 100644 --- a/tests/headless/elision.test.mjs +++ b/tests/headless/elision.test.mjs @@ -26,6 +26,7 @@ function fixture(chunks,document=base){ test('closed create/update/delete lifetime retires without a request and preserves only local high-water marks',()=>{ const original=node(),changed={...original,data:{...original.data,title:'changed'}},f=fixture([[create(original)],[{kind:'field',target:{id:original.id,incarnation:1},field:'data.title',before:'new',after:'changed'}],[remove(changed)]]),proof=f.proof(); assert.deepEqual(proof.operationIds,['op-0','op-1','op-2']);assert.deepEqual(proof.tombstones,{new:1});f.ledger.elide(proof);validateElisions(f.ledger.getSnapshot()); + // 未发送的生命周期不需要服务回执;墓碑只进入本地重放,避免下一次重用相同第一代身份。 assert.equal(f.ledger.getSnapshot().requests.size,0);assert.equal(base.tombstones.size,0);assert.equal(replayPending(base,f.ledger.getSnapshot(),registry).tombstones.get('new'),1); for(const record of f.ledger.getSnapshot().operations.values()){assert.equal(record.resolution,'elided');assert.equal(record.requestId,undefined);} }); @@ -57,6 +58,7 @@ test('unknown, blocked, frozen and possibly-applied creation cannot be elided',( // 伪造高水位、跳过中间观察者或复用同一代身份都被拒绝;失败不能提前解决原操作。 test('a skipped observer, forged tombstone or reused incarnation cannot acquire a local completion proof',()=>{ const n=node(),f=fixture([[create(n)],[remove(n)]]),proof=f.proof();assert.throws(()=>f.ledger.elide({...proof,tombstones:{new:2}}),/invalid_elision/);assert.equal(f.ledger.getSnapshot().operations.get('op-0').phase,'pending'); + // observer 虽无 changes,仍占据中间操作序列;只挑创建/删除两端无法证明整个连续区间都可抵消。 const createIntent={...f.intents[0],sequence:1},deleteIntent={...f.intents[1],sequence:3},ledger=createLedger();ledger.add(createIntent,{new:'absent'});ledger.add({...createIntent,id:'observer',sequence:2,changes:[],dependencies:['op-0']},{});ledger.add(deleteIntent,{new:'exists'}); assert.throws(()=>ledger.elide(proveElision([createIntent,deleteIntent],{})),/elision_not_contiguous/); assert.throws(()=>proveElision([{...createIntent,changes:[create(n),remove(n),create(n),remove(n)]}],{}),/stale_incarnation/); @@ -74,6 +76,7 @@ test('persisted elision groups validate ownership, scope continuity, version and // 即使文档操作已抵消,undo 历史仍引用图片;资源 pin 必须同时保留创建和删除历史的来源。 test('resource references remain pinned by undo history after the document operations retire',()=>{ const picture={...node('picture'),componentId:'image',data:{title:'picture',text:'',src:'asset:asset',assetId:'asset'}},f=fixture([[create(picture)],[remove(picture)]]);f.ledger.elide(f.proof()); + // 抵消的是同步意图,不是用户撤销能力;创建/删除历史条目中的 assetId 仍分别持有资源。 const entries=new Map([['create',{id:'create',changes:[create(picture)],operationIds:['op-0']}],['delete',{id:'delete',changes:[remove(picture)],operationIds:['op-1']}]]),pins=resourcePins({document:base,history:{undo:['create','delete'],redo:[],entries}},f.ledger.getSnapshot(),registry,new Map()); assert.equal(pins.complete,true);assert.ok(pins.pins.get('asset').has('history:create'));assert.ok(pins.pins.get('asset').has('history:delete')); }); diff --git a/tests/headless/history-transition.test.mjs b/tests/headless/history-transition.test.mjs index 5187ddc..20f579c 100644 --- a/tests/headless/history-transition.test.mjs +++ b/tests/headless/history-transition.test.mjs @@ -23,6 +23,7 @@ test('history transition evidence exactly rewinds and replays typing, undo/redo, /** 每次动作捕获前后历史,检查transition版本/修订/entry身份,并双向重放;events随后用于整段回卷。 */ function run(action){const before=editor.getSnapshot(),result=action();assert.equal(result.status,'applied');const after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]),transition=intent.historyTransition;assert.equal(transition.version,1);assert.equal(transition.rootRevision,after.rootRevision);assert.equal(transition.entryId,result.historyEntryId);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));events.push(transition);} run(()=>edit(editor,'one',{groupId:'typing'}));run(()=>edit(editor,'two',{groupId:'typing'}));run(()=>editor.history.undo());run(()=>editor.history.redo());run(()=>editor.history.undo());run(()=>edit(editor,'branch'));run(()=>edit(editor,'third'));run(()=>edit(editor,'fourth')); + // 先反序应用每次增量回到空历史,再正序重放,检查分组追加、redo 清理和容量淘汰的组合关系。 let history=editor.getSnapshot().history;for(const event of [...events].reverse())history=applyHistoryTransition(history,event,'reverse');assert.equal(history.entries.size,0);for(const event of events)history=applyHistoryTransition(history,event,'forward');assert.deepEqual(value(history),value(editor.getSnapshot().history)); }); @@ -56,6 +57,7 @@ test('durable transition metadata survives orphaned redo entries, validates both const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first');await engine.settle();engine.editor.history.undo();await engine.settle();const before=engine.getSnapshot().history;edit(engine.editor,'branch');await engine.settle();const recovered=decodeRecovery(f.image(),registry),last=[...recovered.operations.values()].at(-1);assert.deepEqual(value(applyHistoryTransition(recovered.history,last.historyTransition,'reverse')),value(before)); const bad=f.image(),id=last.id;bad.payload.operations.find(([key])=>key===id)[1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/); const inconsistent=f.image();inconsistent.payload.operations.find(([key])=>key===id)[1].historyTransition.rootRevision++;assert.throws(()=>decodeRecovery(inconsistent,registry),/corrupt_recovery_image/); + // intent 同时存于业务 operations 和 ledger;两份一起替换,隔离未来版本兼容而非副本不一致。 const future=f.image();for(const[key,intent]of future.payload.operations)if(key===id)intent.historyTransition={version:2,preserved:{opaque:true}};for(const[key,record]of future.payload.runtime.ledger.operations)if(key===id)record.intent.historyTransition={version:2,preserved:{opaque:true}};const next=decodeRecovery(future,registry);assert.deepEqual(next.operations.get(id).historyTransition,{version:2,preserved:{opaque:true}});assert.equal(resourcePins(next,next.runtime.ledger,registry,new Map()).complete,false); }finally{await engine.close();} }); @@ -113,6 +115,7 @@ test('geometry undo and redo keep optional presence markers absent and history t // history=false让最终栈为空,仍必须有replacement事件;删掉证据后恢复解码拒绝,不能只凭栈相等接受。 +// 不记用户撤销栈不等于没有逻辑操作;恢复审计仍须证明 replacement 的身份和执行顺序。 test('history-disabled replacement evidence cannot be omitted merely because the final stacks are unchanged',async()=>{ const f=await fixture(),engine=f.engine;try{edit(engine.editor,'untracked',{history:false});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.history.entries.length,0);assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,1);image.payload.runtime.recoveries[0][1].historyReplay.events=[];assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();} }); @@ -154,7 +157,7 @@ test('hidden frozen cycles and sparse transition data do not qualify for cached const editor=createEditor({registry,document});edit(editor,'source'); const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition; const cycle={};Object.defineProperty(cycle,'hidden',{value:cycle});Object.freeze(cycle); - // Non-enumerable metadata is outside JSON validation but must not recurse forever. + // 非枚举元数据不进入 JSON 字段校验,但缓存资格检查仍须安全处理隐藏循环,不误授予所有权证明。 const hidden={...original};Object.defineProperty(hidden,'hidden',{value:cycle});Object.freeze(hidden); assert.notEqual(historyTransitionChanges(hidden),historyTransitionChanges(hidden)); const sparse=Object.freeze([,]);const withSparse={...original};Object.defineProperty(withSparse,'hidden',{value:sparse});Object.freeze(withSparse); diff --git a/tests/headless/ledger.test.mjs b/tests/headless/ledger.test.mjs index de0c3cf..de1b5cf 100644 --- a/tests/headless/ledger.test.mjs +++ b/tests/headless/ledger.test.mjs @@ -29,6 +29,7 @@ test('wholly unsent create-data-delete disappears but a position update preserve const create = { kind: 'element', id: 'a', before: null, after: node }; const rename = { kind: 'field', target: { id: 'a', incarnation: 1 }, field: 'data.title', before: 'a', after: 'renamed', beforePresent: true, afterPresent: true }; const move = { kind: 'field', target: { id: 'a', incarnation: 1 }, field: 'geometry', before: node.geometry, after: { ...node.geometry, x: 100 } }; + // move 替换整个 geometry 字段而非整份节点;压缩后仍需保留此前 rename 写入的独立 data.title。 const compressed = compressChanges([create, rename, move]); assert.equal(compressed.length, 1); assert.equal(compressed[0].after.data.title, 'renamed'); assert.deepEqual(compressChanges([...compressed, { kind: 'element', id: 'a', before: compressed[0].after, after: null }]), []); @@ -40,6 +41,7 @@ test('frozen identity remains exact after unknown/accepted outcomes and deletion const request = frozen(ledger), body = request.body; ledger.markDurable(ledger.getSnapshot().generation); ledger.markDispatch('request'); ledger.result('request', 'unresolved', 'transport_unknown'); + // create/delete 在这个状态机样本中都没有实际 changes;这里只测依赖、风险和冻结身份。 ledger.add(intent('delete', [], ['create']), { a: 'exists' }); assert.equal(ledger.eligible('delete'), false); assert.equal(ledger.possiblyApplied('request'), true); ledger.result('request', 'accepted');