From 47e318682e1ebd9076c5b92b0cd4892e78adf1f7 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 02:29:37 +0800 Subject: [PATCH] docs(tests): explain core invariants and history evidence scenarios --- tests/headless/core.test.mjs | 32 ++++++++++++++++++++++ tests/headless/element-validation.test.mjs | 13 +++++++++ tests/headless/history-transition.test.mjs | 27 ++++++++++++++++++ tests/headless/json-data.test.mjs | 5 ++++ tests/headless/network.test.mjs | 7 +++++ 5 files changed, 84 insertions(+) diff --git a/tests/headless/core.test.mjs b/tests/headless/core.test.mjs index 2b59c3f..ec934cd 100644 --- a/tests/headless/core.test.mjs +++ b/tests/headless/core.test.mjs @@ -1,15 +1,24 @@ +/** + * 核心编辑器的组合回归测试。用内存文档与内置/自定义registry验证事务发布、history、组件兼容和临时手势,不连接服务端。 + * 多数失败断言同时检查根对象身份,确保“拒绝”不仅返回错误,还没有发布中间文档/历史/运行元数据。 + */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createDocument, createEditor, createRegistry, builtinComponents, defineComponent, defineFeature, editable, movable, resizable, rotatable, migrateElement, commonCanvas } from '../../src/lib/canvas-core/index.ts'; +/** 共用合法矩形几何,节点构造器复用该输入;createDocument/editor负责取得自己的不可变数据。 */ const geometry = { x: 10, y: 20, width: 100, height: 80, rotation: 0 }; +/** 默认text节点在root,可替换owner/component构造子图或未知类型。 */ const node = (id, ownerCanvasId = 'root', componentId = 'text') => ({ id, incarnation: 1, ownerCanvasId, componentId, dataVersion: 1, structure: 'node', geometry, data: { title: id, text: '' } }); +/** 固定第一生命周期目标;删除恢复后继续使用它应被拒绝。 */ const target = id => ({ id, incarnation: 1 }); +/** 每个用例隔离editor并使用确定性操作ID;options可覆盖registry/document/policy等依赖。 */ function setup(elements = [node('a'), node('b')], options = {}) { let next = 0; return createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], elements), registry: createRegistry(builtinComponents()), createId: () => `op-${++next}`, ...options }); } +// 让reduceRuntime在特定标题下抛错,检查根完全不变;正常提交后区分rootRevision与文档revision,旧修订号更新须拒绝。 test('runtime preparation shares the document publication boundary; metadata has a separate root revision', () => { const editor = setup(undefined, { runtime: Object.freeze({ ids: [] }), reduceRuntime(previous, next, commit) { if (next.document.elements.get('a').data.title === 'reject-ledger') throw new Error('ledger_failed'); @@ -29,6 +38,7 @@ test('runtime preparation shares the document publication boundary; metadata has assert.equal(editor.getSnapshot().operations.size, 1); assert.equal(editor.getSnapshot().history.undo.length, 1); }); +// 事务首条合法、第二条目标缺失:订阅计数与根身份共同证明没有泄露首条命令的部分结果。 test('a rejected second command publishes no document, history, operation or notification', () => { const editor = setup(), before = editor.getSnapshot(); let notifications = 0; editor.subscribe(() => notifications++); @@ -39,6 +49,7 @@ test('a rejected second command publishes no document, history, operation or not assert.equal(result.status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); assert.equal(notifications, 0); }); +// frame允许移动而不允许旋转;can只查能力,不消耗createId,也不发布根。 test('can is pure and command handlers come from the component capability table', () => { let ids = 0; const frame = node('a', 'root', 'frame'), editor = setup([frame], { createId: () => String(++ids) }); @@ -48,6 +59,7 @@ test('can is pure and command handlers come from the component capability table' assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before); }); +// 跨父子端点的边归root,操作覆盖两canvas;带函数的非JSON元素包络必须拒绝,不能序列化时悄悄丢字段。 test('connection creation guards endpoint canvases and non-JSON envelopes reject without silent truncation',()=>{ const document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[node('a'),node('f','root','frame'),node('b','child')]); const editor=createEditor({registry:createRegistry(builtinComponents()),document}); @@ -56,6 +68,7 @@ test('connection creation guards endpoint canvases and non-JSON envelopes reject const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.create',element:{...node('invalid'),unserializable:()=>42}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before); }); +// 未知组件的constructor/__proto__原样保留,但对可编辑对象注入危险字段被拒绝,并核查原型未污染。 test('opaque JSON keys survive unrelated edits while unsafe field setters remain rejected',()=>{ const future={...node('opaque'),componentId:'future.plugin',dataVersion:99,data:JSON.parse('{"constructor":{"retain":true},"__proto__":{"ordinary":"json data"}}')}; const editor=setup([node('a'),future]);editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}}); @@ -63,6 +76,7 @@ test('opaque JSON keys survive unrelated edits while unsafe field setters remain const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:JSON.parse('{"__proto__":{"polluted":true}}')}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(Object.prototype.polluted,undefined); }); +// 先写null再undo/redo检查字段存在性,随后删除选中节点并dispose会话,验证选择/视图清理。 test('absent and explicit null are distinct history values; view selection follows element deletion', async () => { const editor = setup(); editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } }); @@ -76,6 +90,7 @@ test('absent and explicit null are distinct history values; view selection follo assert.equal(sessions.getSnapshot().views.size, 0); }); +// 动态policy先允许预览,再通过runtime发布触发权限重查;取消原因和下一次begin都必须反映forbidden。 test('permission changes cancel geometry previews and prevent new gestures', async () => { let permitted = true; const editor = setup(undefined, { policy: () => permitted ? { enabled: true } : { enabled: false, code: 'forbidden', targets: ['a'] } }); @@ -88,6 +103,7 @@ test('permission changes cancel geometry previews and prevent new gestures', asy assert.equal(sessions.begin('view', [target('a')]).code, 'forbidden'); sessions.dispose(); }); +// 把标题和位置放进一个事务,再注入不记历史的资源ACK;undo/redo只能改原编辑字段,不能抹掉资源结果。 test('several commands are one history unit; data undo preserves a later resource acknowledgment', () => { const editor = setup(); editor.transaction(tx => { @@ -103,6 +119,7 @@ test('several commands are one history unit; data undo preserves a later resourc assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored-asset'); }); +// 远端后来改同一标题,撤销本地旧值必须拒绝,并保持当前根和栈不变。 test('same-field conflict does not pop history or overwrite remote edits', () => { const editor = setup(); editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'local' } }); @@ -111,6 +128,7 @@ test('same-field conflict does not pop history or overwrite remote edits', () => assert.equal(editor.history.undo().status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); }); +// 删除连带移除边,恢复带回边且incarnation递增;旧目标命令不能修改恢复后的新生命周期。 test('delete undo restores a new incarnation and the original connections; stale targets remain invalid', () => { const edge = { id: 'e', incarnation: 1, ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, structure: 'connection', source: { elementId: 'a' }, target: { elementId: 'b' }, path: null }; const editor = setup([node('a'), node('b'), edge]); @@ -125,6 +143,7 @@ test('delete undo restores a new incarnation and the original connections; stale assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 3); }); +// historyLimit=2检查淘汰;撤销后新分支清redo,同值编辑保持根身份,避免空操作占历史。 test('new user edit clears redo, bounded history evicts old entries, no-op creates no operation', () => { const editor = setup(undefined, { historyLimit: 2 }); for (const title of ['one', 'two', 'three']) editor.dispatch({ type: 'element.data', target: target('a'), patch: { title } }); @@ -137,6 +156,7 @@ test('new user edit clears redo, bounded history evicts old entries, no-op creat assert.strictEqual(editor.getSnapshot(), before); }); +// 异步事务在await前后都有写入尝试;等待微任务后根仍不变,捕获的tx也已关闭。 test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => { const editor = setup(), before = editor.getSnapshot(); let captured; const result = editor.transaction(async tx => { @@ -150,6 +170,7 @@ test('async transactions reject, close their draft, and never publish the asynch assert.throws(() => captured.dispatch({ type: 'element.remove', target: target('a') }), /transaction_closed/); }); +// 编辑已知节点不改未知记录;未知组件自身无法借用未安装行为写入。 test('unknown and future components survive known-object editing but cannot use uninstalled behavior', () => { const unknown = { ...node('future', 'root', 'future/audio'), dataVersion: 99, data: { novel: ['keep', { every: 'field' }] } }; const editor = setup([node('a'), unknown]); @@ -158,6 +179,7 @@ test('unknown and future components survive known-object editing but cannot use assert.equal(editor.dispatch({ type: 'element.data', target: target('future'), patch: { title: 'overwrite' } }).status, 'rejected'); }); +// 重复feature在registry装配时报错;自定义aspect缩放仅改width也应计算相应height。 test('capability conflicts are configuration errors; custom resize handlers can use a different constraint', () => { assert.throws(() => createRegistry([defineComponent({ id: 'bad', dataVersion: 1, structure: 'node', validate: () => true, features: [movable(), movable()] })]), /duplicate_feature/); const registry = createRegistry([defineComponent({ id: 'ratio', dataVersion: 1, structure: 'node', validate: () => true, features: [resizable({ aspect: true })] })]); @@ -166,6 +188,7 @@ test('capability conflicts are configuration errors; custom resize handlers can assert.equal(editor.getSnapshot().document.elements.get('a').geometry.height, 200); }); +// 从v1 title迁为v2 heading,身份/owner稳定且原对象版本仍为1,证明迁移处理副本。 test('migration validates a staged copy and retains stable identity and ownership', () => { const registry = createRegistry([defineComponent({ id: 'text', dataVersion: 2, structure: 'node', features: [editable()], validate: data => typeof data.heading === 'string', migrations: { 1: data => ({ heading: data.title }) } })]); const original = node('a'); @@ -174,6 +197,7 @@ test('migration validates a staged copy and retains stable identity and ownershi assert.equal(original.dataVersion, 1); }); +// 分别构造重复component与不同feature抢同能力,确认配置拒绝;成功装配表也不暴露set。 test('distinct features cannot install the same capability or overwrite an existing component',()=>{ const handler=()=>[],a=defineFeature('first',{'content.edit':handler}),b=defineFeature('second',{'content.edit':handler}); const definition=defineComponent({id:'plugin',dataVersion:1,structure:'node',validate:()=>true,features:[a]}); @@ -183,6 +207,7 @@ test('distinct features cannot install the same capability or overwrite an exist assert.equal(registry.components.set,undefined);assert.equal(registry.handlers.get('plugin').set,undefined); }); +// 遍历缺步骤、抛错、修改冻结输入和非法JSON输出;检查原始值、根、索引及通知全未变,并覆盖未来版本透传与getter只读一次。 test('failed schema or reference migrations retain the original data, version and identity without partial publication',()=>{ const original={...node('a'),data:{title:'Original',links:['b'],nested:{retain:true}}},saved=structuredClone(original); for(const migrations of[ @@ -207,6 +232,7 @@ test('failed schema or reference migrations retain the original data, version an const migrated=migrateElement(original,captured);assert.equal(reads,1);assert.equal(migrated.data.heading,'Captured once');assert.deepEqual(original,saved);assert.ok(Object.isFrozen(migrated.data)); }); +// 事务内部尝试dispatch/transaction/undo/runtime/reconcile,全部重入拒绝;外层空事务也不得触发reduceRuntime或通知。 test('reentrant mutation entry points reject without publishing document, history, runtime, indexes or operations',()=>{ for(const attempt of[ editor=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'nested'}}), @@ -221,6 +247,7 @@ test('reentrant mutation entry points reject without publishing document, histor } }); +// 两兄弟canvas共同上级为root;把root挂到其后代会成环,须拒绝且保持根不变。 test('cross-child connection belongs to the common ancestor; cyclic canvas ownership rejects atomically', () => { const elements = [node('f'), node('g'), node('a', 'child'), node('b', 'sibling')]; const canvases = [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'f' } }, { id: 'sibling', parent: { canvasId: 'root', entryElementId: 'g' } }]; @@ -232,6 +259,7 @@ test('cross-child connection belongs to the common ancestor; cyclic canvas owner assert.strictEqual(editor.getSnapshot(), before); }); +// 快照集合无set,订阅者抛错也不能回滚已发布的合法提交;观察通知与提交成功边界分开。 test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => { const editor = setup(); assert.equal(editor.getSnapshot().document.elements.set, undefined); @@ -240,6 +268,7 @@ test('snapshot collections expose no mutation methods and observer failures do n assert.equal(editor.getSnapshot().document.elements.get('a').data.title, 'committed'); }); +// 连续预览不改根;期间资源ACK正常合入,取消只撤预览。第二场手势提交才产生一个history条目。 test('gesture preview is transient, cancel preserves an upload ACK, commit creates one history entry', async () => { const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view'); @@ -257,6 +286,7 @@ test('gesture preview is transient, cancel preserves an upload ACK, commit creat detach(); sessions.dispose(); }); +// 远端几何覆盖导致预览取消;之后detach再次取消手势,文档保持远端已提交位置。 test('geometry conflict and view detach cancel only that gesture, without deleting model content', async () => { const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view'); @@ -268,12 +298,14 @@ test('geometry conflict and view detach cancel only that gesture, without deleti sessions.dispose(); }); +// prepareIntent伪造操作ID并清changes,必须在发布前拒绝,不允许传输准备重写业务事实。 test('transport preparation cannot rewrite a committed document change or its identity',()=>{ const editor=setup(undefined,{prepareIntent:intent=>({...intent,id:'another-operation',changes:[]})}); const before=editor.getSnapshot(),result=editor.dispatch({type:'element.data',target:target('a'),patch:{text:'must not publish'}}); assert.equal(result.code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before); }); +// 300次同组修改形成大历史,假时钟迫使编码让步;编码期间再编辑,旧镜像仍只含捕获的300项,当前undo仍正确。 test('growing grouped history can yield during recovery encoding without altering undo data',async()=>{ const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); const editor=setup(); diff --git a/tests/headless/element-validation.test.mjs b/tests/headless/element-validation.test.mjs index d2618bc..59f4085 100644 --- a/tests/headless/element-validation.test.mjs +++ b/tests/headless/element-validation.test.mjs @@ -1,34 +1,47 @@ +/** + * 元素静态校验缓存的正确性测试。区分框架取得所有权的不可变记录与调用方浅冻结/访问器输入,验证优化不能绕过动态schema或文档拓扑。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createDocument,validateElementRecord,validateDocument,validateDocumentSteps,createRegistry,defineComponent,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts'; +/** probe记录包含合法几何与最小正文;可改字段用来制造尺寸、owner或引用错误。 */ const node=(id='a')=>({id,incarnation:1,ownerCanvasId:'root',componentId:'probe',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'hello'}}); +/** 自定义validator带可切换allowed和调用计数,验证静态证明命中后schema仍按当前状态执行。 */ function setup(){let allowed=true,calls=0;const component=defineComponent({id:'probe',dataVersion:1,structure:'node',features:[],validate:data=>{calls++;return allowed&&typeof data.text==='string';}}),registry=createRegistry([...builtinComponents(),component]),document=createDocument('p',[{id:'root',parent:null}],[node()]);return{registry,document,allow:value=>{allowed=value;},calls:()=>calls};} +// 暂替Object.values检测是否重复遍历自有记录,finally恢复全局函数;schema计数、拒绝开关与墓碑仍实时检查。 test('owned record proofs skip repeated static traversal while schema and tombstones remain live checks',()=>{ const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const count=f.calls(),values=Object.values;try{Object.values=value=>{if(value===record||value===record.data)throw Error('owned static data enumerated again');return values(value);};validateElementRecord(record,f.registry);}finally{Object.values=values;}assert.equal(f.calls(),count+1);f.allow(false);assert.throws(()=>validateElementRecord(record,f.registry),/invalid_component_data/);f.allow(true);assert.throws(()=>validateElementRecord(record,f.registry,new Map([['a',1]])),/stale_incarnation/); }); +// 先验证合法图,再移走owner或端点;缓存的元素静态证明不能替代新文档关系校验。 test('static record proofs never certify owner membership or connection endpoints',()=>{ const f=setup(),b={...node('b'),geometry:{...node().geometry,x:200}},edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null},document=createDocument('p',[{id:'root',parent:null}],[node(),b,edge]);validateDocument(document,f.registry);assert.throws(()=>validateDocument({...document,canvases:new Map([['other',{id:'other',parent:null}]])},f.registry),/invalid_element/);assert.throws(()=>validateDocument({...document,elements:new Map([...document.elements].filter(([id])=>id!=='b'))},f.registry),/invalid_endpoint/); }); +// 浅冻结对象内部几何仍可变,getter也可换返回值;再次校验必须发现非法尺寸,且不得冻结调用方嵌套对象。 test('external shallow-frozen records and getters are revalidated and never frozen on behalf of callers',()=>{ const f=setup(),raw=Object.freeze(node());validateElementRecord(raw,f.registry);raw.geometry.width=0;assert.throws(()=>validateElementRecord(raw,f.registry),/invalid_geometry/);assert.equal(Object.isFrozen(raw.geometry),false);let geometry={...node().geometry};const getter=Object.freeze({...node(),get geometry(){return geometry;}});validateElementRecord(getter,f.registry);geometry={...geometry,width:-1};assert.throws(()=>validateElementRecord(getter,f.registry),/invalid_geometry/); }); +// 创建后破坏外部输入不影响editor记录;跳过schema的一次拓扑检查不能授予以后跳过schema的证明。 test('editor creations detach caller data; topology-only validation cannot bypass later schema checks',()=>{ const f=setup();let id=0;const editor=createEditor({document:f.document,registry:f.registry,createId:()=>`op-${++id}`}),incoming=node('new');assert.equal(editor.dispatch({type:'element.create',element:incoming}).status,'applied');incoming.geometry.width=-1;incoming.data.text=undefined;const record=editor.getSnapshot().document.elements.get('new');assert.equal(record.geometry.width,100);assert.equal(record.data.text,'hello');const fresh=createDocument('p',[{id:'root',parent:null}],[node('topology')]).elements.get('topology');f.allow(false);const calls=f.calls();validateElementRecord(fresh,f.registry,new Map(),false);assert.equal(f.calls(),calls);assert.throws(()=>validateElementRecord(fresh,f.registry),/invalid_component_data/);editor.dispose(); }); +// 用空canvas、墓碑、缺端点及非法几何比较两种遍历结果,再检查合法迭代器的让步步数。 test('synchronous and cooperative validation preserve check order and the same rejection results',()=>{ const f=setup(),base=f.document,edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'missing'},path:null},cases=[base,{...base,canvases:new Map()}, {...base,tombstones:new Map([['a',0]])},{...base,tombstones:new Map([['a',1]])},{...base,canvases:new Map([['root',{id:'root',parent:null}],['other',{id:'other',parent:null}]])},createDocument('p',[{id:'root',parent:null}],[node(),edge]),createDocument('p',[{id:'root',parent:null}],[{...node(),geometry:{...node().geometry,width:0}}])]; + /** 将抛错规范为code/targets或valid,比较同步与协作遍历的拒绝结果,而非依赖异常对象引用。 */ const outcome=callback=>{try{callback();return'valid';}catch(error){return{code:error.code,targets:error.targets};}}; for(const document of cases)assert.deepEqual(outcome(()=>validateDocument(document,f.registry)),outcome(()=>{for(const _ of validateDocumentSteps(document,f.registry)){};})); const iterator=validateDocumentSteps(base,f.registry);assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.equal(iterator.next().done,true); }); +// 内置数据校验可复用证明,自定义同ID schema仍执行并保留this;失败校验和墓碑不能被缓存成成功。 test('only framework data-only validators can reuse a data proof; wrapped same-ID custom schemas stay live with their receiver',()=>{ const registry=createRegistry(builtinComponents()),input={...node(),componentId:'text',data:{title:'A',text:''}},record=createDocument('p',[{id:'root',parent:null}],[input]).elements.get('a');validateElementRecord(record,registry);const values=Object.values;try{Object.values=value=>{if(value===record.data)throw Error('framework data schema was repeated');return values(value);};validateElementRecord(record,registry);}finally{Object.values=values;} let allowed=true,calls=0;const custom=defineComponent({id:'text',dataVersion:1,structure:'node',features:[],validate:function(data){calls++;assert.equal(this.id,'text');return allowed&&typeof data.title==='string';}}),other=createRegistry([custom]);validateElementRecord(record,other);allowed=false;assert.throws(()=>validateElementRecord(record,other),/invalid_component_data/);assert.equal(calls,2);assert.throws(()=>validateElementRecord(record,registry,new Map([['a',1]])),/stale_incarnation/); const malformed=createDocument('p',[{id:'root',parent:null}],[{...input,data:{title:7,text:''}}]).elements.get('a');assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/);assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/); }); +// 把框架记录重新装进文档可保留引用,外部或浅冻结仿制记录须复制;切动态schema后仍能拒绝。 test('core-owned immutable element data survives document re-enveloping without aliasing caller-owned records',()=>{ const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const next=createDocument('p',[...f.document.canvases.values()],[record]);assert.strictEqual(next.elements.get('a'),record);const external=node('external'),copy=createDocument('p',[{id:'root',parent:null}],[external]);assert.notStrictEqual(copy.elements.get('external'),external);external.data.text='later';assert.equal(copy.elements.get('external').data.text,'hello');const lookalike=Object.freeze({...record});assert.notStrictEqual(createDocument('p',[{id:'root',parent:null}],[lookalike]).elements.get('a'),lookalike);f.allow(false);assert.throws(()=>validateDocument(next,f.registry),/invalid_component_data/); }); diff --git a/tests/headless/history-transition.test.mjs b/tests/headless/history-transition.test.mjs index 63fd5a3..5187ddc 100644 --- a/tests/headless/history-transition.test.mjs +++ b/tests/headless/history-transition.test.mjs @@ -1,3 +1,7 @@ +/** + * 历史栈变更证据的回放与恢复测试。验证每个transition可正反应用、只存增量范围,并在本地恢复/冲突方案中保留可验证顺序。 + * 内存storage及固定拒绝的persistence制造可重复的冲突环境,不代表真实磁盘崩溃或服务端集成验证。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createEditor,createDocument,createRegistry,builtinComponents,applyHistoryTransition,validateHistoryTransition,historyTransitionChanges,describeHistoryTransition,readMap} from '../../src/lib/canvas-core/index.ts'; @@ -6,21 +10,28 @@ import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts'; import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts'; import {replayHistoryEvidence,sameHistory,validateHistoryReplay} from '../../src/lib/canvas-runtime/history-replay.ts'; import {resourcePins} from '../../src/lib/canvas-runtime/resources.ts'; +// 共用只读registry和原始文档,每个editor/engine另建状态,避免测试相互继承历史。 const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'root',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument('project',[{id:'root',parent:null}],[node]); +/** 历史转成可深比较的栈与排序entries数组,忽略Map迭代次序差异,保留条目实际内容。 */ const value=h=>({undo:h.undo,redo:h.redo,entries:[...h.entries].sort(([a],[b])=>a.localeCompare(b))}); +/** 对同一第一生命周期节点改正文,settings控制分组或不记历史。 */ const edit=(editor,text,settings)=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text}},settings); +// 同组输入、undo/redo、切分支及容量淘汰逐项记证据,再按反序退空、正序恢复最终历史。 test('history transition evidence exactly rewinds and replays typing, undo/redo, redo invalidation and capacity eviction',()=>{ const editor=createEditor({registry,document,historyLimit:2}),events=[]; + /** 每次动作捕获前后历史,检查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')); 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)); }); +// 同组80次编辑后检查最后transition偏移79且只追加一项,防止每次都复制全部累计历史。 test('typing transitions append only new ranges rather than embedding the accumulated entry',()=>{ const editor=createEditor({registry,document});for(let i=0;i<80;i++)edit(editor,'text '+i,{groupId:'typing'});const intent=[...editor.getSnapshot().operations.values()].at(-1),patch=intent.historyTransition.entries[0];assert.equal(patch.changes.offset,79);assert.equal(patch.changes.before.length,0);assert.equal(patch.changes.after.length,1);assert.equal(patch.operationIds.after.length,1);assert.equal(historyTransitionChanges(intent.historyTransition).length,1); }); +// 篡改栈/条目内容制造回放冲突,非法偏移或条目ID也拒绝;未来解释版本走明确不可解释错误。 test('transition replay is immutable and rejects changed stack or changed replaced range',()=>{ const editor=createEditor({registry,document}),result=edit(editor,'one'),snapshot=editor.getSnapshot(),transition=snapshot.operations.get(result.operationIds[0]).historyTransition,entry=snapshot.history.entries.values().next().value; assert.throws(()=>applyHistoryTransition({...snapshot.history,undo:[]},transition,'reverse'),/history_transition_conflict/); @@ -28,15 +39,19 @@ test('transition replay is immutable and rejects changed stack or changed replac const invalid=structuredClone(transition);invalid.entries[0].changes.offset=999;assert.throws(()=>validateHistoryTransition(invalid),/invalid_history_transition/);const reserved=structuredClone(transition);reserved.entries[0].after.id='foreign';assert.throws(()=>validateHistoryTransition(reserved),/invalid_history_transition/);assert.throws(()=>applyHistoryTransition(snapshot.history,{...transition,version:2},'reverse'),/history_transition_interpreter_unavailable/); }); +// 传输准备层伪造空entries不得通过,editor根保持原对象。 test('prepareIntent cannot substitute forged local history evidence',()=>{ const editor=createEditor({registry,document,prepareIntent:intent=>({...intent,historyTransition:{...intent.historyTransition,entries:[]}})}),before=editor.getSnapshot();assert.equal(edit(editor,'forged').code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before); }); +/** 内存镜像clone隔离,提交只核对leader;服务端固定not-applied/version_conflict,read返回原始基准。 + * 返回image读取器用于破坏副本测试decodeRecovery,调用方finally关闭engine。 */ async function fixture(){ let image,leader;const storage={async claim(scope){return leader={scope,active:true,fence:1,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},close(){}}; const persistence={compile(intent){return{payload:{changes:intent.changes},canvasIds:intent.canvasIds,expectedVersions:{root:1}};},async submit(){return{outcome:'not-applied',error:'version_conflict'};},async query(){throw Error('unexpected');},async read(){return{document,versions:new Map([['root',1]])};}}; const engine=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document,versions:new Map([['root',1]]),storage,persistence});return{engine,image:()=>structuredClone(image)}; } +// 从恢复镜像反推分支前历史;破坏操作证据或修订被判损坏,未来版本需两份同步保留且使资源扫描不完整。 test('durable transition metadata survives orphaned redo entries, validates both copies and keeps future versions opaque',async()=>{ 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/); @@ -45,17 +60,20 @@ test('durable transition metadata survives orphaned redo entries, validates both }finally{await engine.close();} }); +// 用不会联网的transport只测试compile,服务操作仅有changes/id,不发送本地historyTransition。 test('local transition evidence does not enter the protocol 2 service payload',()=>{ const root='10000000-0000-4000-8000-000000000001',project=createDocument(root,[{id:root,parent:null}],[{...node,ownerCanvasId:root}]),editor=createEditor({registry,document:project}),result=edit(editor,'new'),intent=editor.getSnapshot().operations.get(result.operationIds[0]);const port=createFoundryPersistence({workspaceId:'10000000-0000-4000-8000-000000000002',rootId:root,registry,transport:async()=>{throw Error('unexpected network');}}),request=port.compile(intent,'10000000-0000-4000-8000-000000000003',{...editor.getSnapshot(),runtime:{versions:new Map([[root,1]])}});assert.deepEqual(Object.keys(request.payload.operations[0]).sort(),['changes','id']);assert.equal(JSON.stringify(request.payload).includes('historyTransition'),false); }); +// 图片已从文档和现有history移除,早期undo未解决时仍由后续transition pin资源;全部确认后才解除。 test('cleared redo resource pins survive a later confirmed branch while an earlier text undo is unresolved',()=>{ const editor=createEditor({registry,document});edit(editor,'edited');editor.dispatch({type:'element.create',element:{...node,id:'image',componentId:'image',data:{title:'Image',text:'',src:'https://assets.example/image',assetId:'asset'}}});editor.history.undo();const pending=editor.history.undo();edit(editor,'branch');const snapshot=editor.getSnapshot(),last=[...snapshot.operations.values()].at(-1),operations=new Map([...snapshot.operations].map(([id,intent])=>[id,{intent,phase:id===pending.operationIds[0]?'pending':'resolved',blockers:[],basePresence:{}}])); assert.equal(snapshot.document.elements.has('image'),false);assert.equal([...snapshot.history.entries.values()].some(entry=>entry.changes.some(change=>change.kind==='element'&&change.id==='image')),false); const pins=resourcePins(snapshot,{operations},registry,new Map());assert.deepEqual([...pins.pins.get('asset')],[`history-transition:${last.id}`]);operations.set(pending.operationIds[0],{...operations.get(pending.operationIds[0]),phase:'resolved'});assert.equal(resourcePins(snapshot,{operations},registry,new Map()).pins.has('asset'),false); }); +// keep-local方案带采用证据,新replacement操作不继承旧transition;保存后解码保留证据,篡改范围被拒绝。 test('conflict recovery records one adoption transition and never copies stale evidence to replacement requests',async()=>{ const f=await fixture(),engine=f.engine;try{edit(engine.editor,'pending');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'])));assert.ok(plan.audit.historyTransition);assert.deepEqual(value(applyHistoryTransition(plan.history,plan.audit.historyTransition,'reverse')),value(before.history));assert.equal(plan.operations.get(plan.operationIds[0]).historyTransition,undefined);await session.resolve(plan);await engine.settle();const recovered=decodeRecovery(f.image(),registry);assert.deepEqual(recovered.runtime.recoveries.get(plan.id).historyTransition,plan.audit.historyTransition); const bad=f.image();bad.payload.runtime.recoveries[0][1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/); @@ -63,18 +81,21 @@ test('conflict recovery records one adoption transition and never copies stale e }); +// 改label时保留未来嵌套元数据,反向应用仍精确恢复before。 test('transition patches preserve opaque history-entry metadata alongside changed fields',()=>{ const editor=createEditor({registry,document});edit(editor,'one');const history=editor.getSnapshot().history,id=history.undo[0],original={...history.entries.get(id),future:{nested:{keep:true}}},before={...history,entries:new Map([[id,original]])},after={...history,entries:new Map([[id,{...original,label:'New label'}]])},transition=describeHistoryTransition(before,after,10); const result=applyHistoryTransition(before,transition,'forward');assert.deepEqual(result.entries.get(id).future,{nested:{keep:true}});assert.deepEqual(value(applyHistoryTransition(result,transition,'reverse')),value(before)); }); +// 创建、改名、删除再恢复导致incarnation=2,证据须覆盖多个旧条目并能双向还原映射。 test('restoration evidence reverses incarnation remapping across earlier history entries',()=>{ const editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:{...node,id:'new'}});editor.dispatch({type:'element.data',target:{id:'new',incarnation:1},patch:{title:'edited'}});editor.dispatch({type:'element.remove',target:{id:'new',incarnation:1}});const before=editor.getSnapshot(),result=editor.history.undo(),after=editor.getSnapshot(),transition=after.operations.get(result.operationIds[0]).historyTransition; assert.equal(after.document.elements.get('new').incarnation,2);assert.ok(transition.entries.length>=3);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history)); }); +// 分别测几何与同组文本:采用锚点+replacement序列重放应等于plan.history;缺事件/缺操作拒绝,未来版本保留opaque。 test('adoption evidence composes the anchor and replacement events into the exact planned history and survives decoding',async()=>{ for(const geometry of [false,true]){ const f=await fixture(),engine=f.engine;try{if(geometry)engine.editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});else edit(engine.editor,'first',{groupId:'typing'});await engine.settle();if(!geometry)edit(engine.editor,'second',{groupId:'typing'});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),selected=new Map([...before.runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local'])),plan=session.plan(selected),proof=plan.audit.historyReplay;assert.ok(proof);validateHistoryReplay(proof,before.rootRevision+1);assert.ok(sameHistory(replayHistoryEvidence(before.history,proof,id=>plan.operations.has(id)),plan.history));assert.deepEqual(proof.events.filter(event=>event.kind==='operation').map(event=>event.operationId),plan.operationIds);await session.resolve(plan);await engine.settle();const image=f.image(),decoded=decodeRecovery(image,registry);assert.deepEqual(decoded.runtime.recoveries.get(plan.id).historyReplay,proof); @@ -85,20 +106,24 @@ test('adoption evidence composes the anchor and replacement events into the exac } }); +// 几何不是可选data字段,undo/redo不得额外引入presence标记;证据JSON往返及正向回放仍一致。 test('geometry undo and redo keep optional presence markers absent and history transitions serializable',()=>{ const editor=createEditor({registry,document});editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});for(const direction of ['undo','redo']){const before=editor.getSnapshot(),result=editor.history[direction](),after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]);assert.equal(Object.hasOwn(intent.changes[0],'beforePresent'),false);assert.equal(Object.hasOwn(intent.changes[0],'afterPresent'),false);assert.deepEqual(JSON.parse(JSON.stringify(intent.historyTransition)),intent.historyTransition);assert.ok(sameHistory(applyHistoryTransition(before.history,intent.historyTransition,'forward'),after.history));} }); +// history=false让最终栈为空,仍必须有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();} }); +// 两次不记历史操作的证据仍有顺序,反转events必须判损坏。 test('adoption evidence preserves replacement sequence even when all history transitions are no-ops',async()=>{ const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first',{history:false});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}},{history:false});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map([...engine.getSnapshot().runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,2);image.payload.runtime.recoveries[0][1].historyReplay.events.reverse();assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();} }); +// 内部不可变transition复用冻结范围数组,但调用方validator仍逐条执行并可拒绝当前schema。 test('resource scans reuse validated immutable transition ranges without skipping caller validation',()=>{ const editor=createEditor({registry,document});edit(editor,'cached'); const transition=[...editor.getSnapshot().operations.values()].at(-1).historyTransition; @@ -108,6 +133,7 @@ test('resource scans reuse validated immutable transition ranges without skippin assert.throws(()=>validateHistoryTransition(transition,()=>{throw Error('current schema rejected');}),/current schema rejected/); }); +// 改变可变/浅冻结/getter输入后扫描须读新值,非法偏移须拒绝,不能套用内部证明缓存。 test('mutable, shallow frozen and accessor transition evidence remains live across resource scans',()=>{ const editor=createEditor({registry,document});edit(editor,'source'); const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition; @@ -123,6 +149,7 @@ test('mutable, shallow frozen and accessor transition evidence remains live acro assert.equal(historyTransitionChanges(accessor)[0].after,'getter changed'); }); +// 非枚举隐藏循环和稀疏数组不能递归死循环,也不能取得可复用的范围缓存资格。 test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{ const editor=createEditor({registry,document});edit(editor,'source'); const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition; diff --git a/tests/headless/json-data.test.mjs b/tests/headless/json-data.test.mjs index 703df70..53734bc 100644 --- a/tests/headless/json-data.test.mjs +++ b/tests/headless/json-data.test.mjs @@ -1,7 +1,12 @@ +/** + * JSON隔离函数的语义与所有权测试。以日期、toJSON、稀疏数组、特殊键及循环等输入核对JSON转换与深冻结。 + * 所有权证明只认可函数自己产生的结果,不能因调用方freeze或clone就复用内部证明。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {detachJsonData,hasDetachedJsonData} from '../../src/lib/canvas-core/json-data.ts'; +// 修改外部嵌套对象/日期不得影响结果;数组空洞等变null,特殊键保留,伪造冻结副本无证明,循环/BigInt明确抛错。 test('JSON detachment owns only its copied immutable result and preserves JSON semantics',()=>{ let calls=0;const external={nested:{text:'before'},date:new Date('2026-01-01Z'),custom:{toJSON(){calls++;return{value:2};}},array:[,undefined,NaN],['__proto__']:{safe:true}}; const value=detachJsonData(external);assert.equal(calls,1);assert.equal(hasDetachedJsonData(value),true);assert.equal(hasDetachedJsonData(external),false); diff --git a/tests/headless/network.test.mjs b/tests/headless/network.test.mjs index 2ff8de0..2f2e280 100644 --- a/tests/headless/network.test.mjs +++ b/tests/headless/network.test.mjs @@ -1,10 +1,17 @@ +/** + * 网络准入门的微任务级测试。容量设为1,用手动Promise控制排队顺序,验证拿到名额后仍要核对身份与取消。 + * 这里不发真实HTTP,也不测试服务端幂等或浏览器网络栈。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createNetworkGate} from '../../src/lib/canvas-runtime/network.ts'; +/** 暴露resolve的手动等待点,让首请求占住名额,测试可在第二请求获准前改变身份或abort。 */ const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{resolve,promise};}; +// 首请求等待时使身份失效,次请求不执行;恢复身份后第三请求能执行,证明拒绝的名额已释放。 test('transport rechecks identity after waiting for capacity and releases a rejected permit',async()=>{ let current=true,calls=0;const gate=createNetworkGate(1,()=>{if(!current)throw Error('identity_changed');}),hold=deferred(),signal=new AbortController().signal,first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(signal,async()=>{calls++;}),/identity_changed/);await Promise.resolve();current=false;hold.resolve();await first;await second;assert.equal(calls,1);current=true;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2); }); +// 排队请求先abort,随后释放首请求并运行第三个;调用数与护栏计数都只能包含真正获准的两个任务。 test('an aborted queued transport cannot pass the effect guard or consume the next capacity grant',async()=>{ let guards=0,calls=0;const gate=createNetworkGate(1,()=>guards++),hold=deferred(),signal=new AbortController().signal,abort=new AbortController(),first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(abort.signal,async()=>{calls++;}),/cancelled/);await Promise.resolve();abort.abort(Error('cancelled'));await second;hold.resolve();await first;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);assert.equal(guards,2); });