docs(tests): explain recovery encoding journals and applied receipts
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 服务端已证明 applied、但本地尚未完成 canonical 接纳时的恢复测试。
|
||||
* 模拟 codec 暂不可用、新远端编辑、持久化失败及回执过期,验证恢复不会重发已成功操作或提前释放 writer。
|
||||
* 这里使用内存传输和克隆快照存储,覆盖协议协调,不覆盖真实数据库或浏览器存储实现。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,25 +10,37 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
|
||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
|
||||
// 固定 UUID 作用域与元素身份,满足 Foundry adapter 的请求验证要求。
|
||||
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
|
||||
// 文本元素及其第一代身份,远端几何修改和本地命令都以这一身份为条件。
|
||||
const target={id:'a',incarnation:1},element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}};
|
||||
// 内置定义与初始文档;fixture 用新的 server 引用演进,保留原始文档作为恢复对照。
|
||||
const registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[element]);
|
||||
// 构造可注入 codec、配额和过旧读取故障的服务端/存储环境,记录全部请求用于检查重复写入。
|
||||
function fixture(){
|
||||
let server=original,version=1,image,leader;const calls=[],receipts=new Map();
|
||||
// 把指定文档和版本封装成服务响应;complete=false 表示回执附带的局部模型,而非完整恢复读取。
|
||||
const model=(document=server,currentVersion=version,complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:{[root]:currentVersion},removedCanvasIds:[]});
|
||||
// 存储 claim 检查单 leader;commit 在克隆完整 image 前检查配额及 owner,remoteGeometry 则模拟其他客户端提交。
|
||||
const f={failCodec:true,broken:false,behind:false,calls,receipts,storage:{async claim(scope){assert.equal(leader,undefined);return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},image:()=>structuredClone(image),remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;}};
|
||||
// GET 分别提供模型与回执;POST 先检查 expected_versions,再应用操作、增加版本并保存可查询回执。
|
||||
const transport=async request=>{
|
||||
calls.push(request);if(request.method==='GET'){if(request.path.endsWith('/model'))return{status:200,body:{model:f.behind?model(original,1):model()}};const requestId=request.path.split('/').at(-1),receipt=receipts.get(requestId);return receipt?{status:200,body:receipt}:{status:404,body:{protocol_version:2,request_id:requestId,status:'unknown'}};}
|
||||
// 此假服务只支持本用例的顺序写入:按请求内操作应用 changes,并以同一 requestId 记录成功证据。
|
||||
const input=JSON.parse(request.body);assert.equal(input.expected_versions[root],version);for(const operation of input.operations)server=applyChanges(server,operation.changes);version++;
|
||||
const receipt={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:input.operations.map(operation=>operation.id),model:model(server,version,false)};receipts.set(request.requestId,receipt);return{status:200,body:receipt};
|
||||
};
|
||||
const base=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
|
||||
// 只让 canonical 接纳暂时失败;网络提交仍成功,从而停在 applied 但 phase=waiting 的待协调状态。
|
||||
f.port={...base,canonical(...args){if(f.failCodec)throw Error('codec_temporarily_unavailable');return base.canonical(...args);}};
|
||||
// 以同一作用域及存储重新打开引擎,使关闭后的恢复确实读取上次保存的 image。
|
||||
f.open=()=>openProjectEngine({scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage:f.storage,persistence:f.port});
|
||||
return f;
|
||||
}
|
||||
// 先提交一次改名并确认网络成功、本地接纳等待;后续各用例都从这个关键中间状态开始。
|
||||
async function applied(f){const engine=await f.open();engine.editor.dispatch({type:'element.data',target,patch:{title:'first'}});await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');return{engine,request};}
|
||||
|
||||
// 远端又修改几何后,恢复以较新的完整读取接纳已成功请求;保留原历史与冻结字节,重开后也不增加 POST。
|
||||
test('a verified applied request adopts a newer full read without reapplying its old receipt or generating a new write',async()=>{
|
||||
const f=fixture(),{engine,request}=await applied(f);f.remoteGeometry({x:500,width:300});const history=engine.getSnapshot().history,session=await engine.prepareRecovery();
|
||||
assert.deepEqual(session.acknowledgedRequestIds,[request.id]);assert.deepEqual(session.pendingOperationIds,[]);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');
|
||||
@@ -32,6 +49,7 @@ test('a verified applied request adopts a newer full read without reapplying its
|
||||
await engine.close();const recovered=await f.open();await recovered.settle();assert.equal(recovered.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await recovered.close();
|
||||
});
|
||||
|
||||
// 把成功确认和显式 keep-local 重规划一起持久化;配额失败不能派发后续写,重试后撤销仍保留远端的新宽度。
|
||||
test('acknowledgement and explicit replan commit together; original writer releases only after the new cut is durable',async()=>{
|
||||
const f=fixture(),{engine,request}=await applied(f);const edit=engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}});await engine.settle();f.remoteGeometry({x:500,width:300});
|
||||
const session=await engine.prepareRecovery();assert.ok(session.conflict);assert.deepEqual(session.pendingOperationIds,edit.operationIds);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));
|
||||
@@ -42,11 +60,13 @@ test('acknowledgement and explicit replan commit together; original writer relea
|
||||
assert.equal(decodeRecovery(f.image(),registry).runtime.ledger.requests.get(request.id).body,request.body);await engine.close();
|
||||
});
|
||||
|
||||
// 取消恢复预览应保持整个原快照、waiting 请求和历史不变,不产生第二次 POST。
|
||||
test('a cancelled acknowledgement preview leaves the applied request pending and its original history untouched',async()=>{
|
||||
const f=fixture(),{engine,request}=await applied(f),before=engine.getSnapshot(),session=await engine.prepareRecovery();await session.cancel();
|
||||
assert.equal(engine.getSnapshot(),before);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await engine.close();
|
||||
});
|
||||
|
||||
// 完整读取落后于成功回执时拒绝接纳;回执覆盖错误和缺失在再次读模型前拒绝,不能靠新写入消除疑点。
|
||||
test('a fresh read behind a proven receipt cannot release its claim; malformed and missing proofs remain preserved',async()=>{
|
||||
const f=fixture(),{engine,request}=await applied(f);f.behind=true;await assert.rejects(engine.prepareRecovery(),/recovery_read_behind_receipt/);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');
|
||||
f.behind=false;const beforeReads=f.calls.filter(call=>call.path.endsWith('/model')).length;
|
||||
@@ -54,6 +74,7 @@ test('a fresh read behind a proven receipt cannot release its claim; malformed a
|
||||
assert.equal(f.calls.filter(call=>call.path.endsWith('/model')).length,beforeReads);engine.editor.updateRuntime(runtime=>({...runtime,receipts:new Map()}));await assert.rejects(engine.prepareRecovery(),/recovery_receipt_missing/);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await engine.close();
|
||||
});
|
||||
|
||||
// 关闭并重开后,即使服务端回执已过期,本地持久化成功证据仍可配合完整读取完成接纳,无需重发。
|
||||
test('a locally durable success proof survives server receipt expiry without resending the original operation',async()=>{
|
||||
const f=fixture(),{engine,request}=await applied(f);await engine.close();f.receipts.clear();const recovered=await f.open();await recovered.settle();
|
||||
const session=await recovered.prepareRecovery();assert.deepEqual(session.acknowledgedRequestIds,[request.id]);await session.apply();await recovered.settle();
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
/**
|
||||
* 恢复日志的结构差分与重放测试。补丁需精确还原 JSON 的可观察顺序和内容,并保持先前状态根不变。
|
||||
* 小修改应避免重复大 payload;非法路径、稀疏数组或超限变化不能被当作有效日志。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {diffJournal,applyJournal} from '../../src/lib/canvas-runtime/journal-delta.ts';
|
||||
// 共同断言:计算差分、重放后逐字比较 JSON,同时确认原状态没被修改,返回补丁供体积断言。
|
||||
const replay=(before,after)=>{const captured=JSON.stringify(before),patches=diffJournal(before,after);assert.ok(patches);const result=applyJournal(before,patches);assert.equal(JSON.stringify(result),JSON.stringify(after));assert.equal(JSON.stringify(before),captured);return patches;};
|
||||
// 覆盖根值替换、数组缩短/增长、对象键重排、嵌套编辑及 own __proto__,验证精确往返且不污染对象原型。
|
||||
test('journal patches preserve serialized property order, arrays, null and own dangerous-name payloads',()=>{
|
||||
for(const[before,after]of [[null,{a:1}],[[1,2,3],[1,9]],[[1],[2,3,4]],[{a:1,b:2},{b:2,a:1}],[JSON.parse('{"__proto__":{"x":1}}'),JSON.parse('{"__proto__":{"x":2}}')],[{nested:[{id:'A',data:{text:'before'}}]},{nested:[{id:'A',data:{text:'after'}},{id:'B'}]}]])replay(before,after);
|
||||
assert.equal({}.x,undefined);
|
||||
});
|
||||
// 1000 行大表只改最后一行再追加一行,要求两个补丁总编码小于 1000 字符,并保留冻结账本字节。
|
||||
test('a small node update and appended table row do not repeat the unchanged large payload',()=>{
|
||||
const rows=Array.from({length:1000},(_,i)=>({id:String(i),text:'x'.repeat(1000)})),before={rows,ledger:{body:'exact frozen bytes'}},after={rows:[...rows.slice(0,999),{...rows[999],text:'changed'},{id:'new',text:'new'}],ledger:before.ledger};
|
||||
const patches=replay(before,after);assert.ok(JSON.stringify(patches).length<1000);assert.equal(patches.length,2);
|
||||
});
|
||||
// 逐一拒绝不存在的父路径、数组越界/字符串索引、非法 splice、未知 op 及 undefined;补丁数超限也拒绝,差分超限返回 null。
|
||||
test('malformed deltas reject invalid paths, array holes, unsupported operations and invalid ranges',()=>{
|
||||
for(const patch of [{op:'set',path:['missing','x'],value:1},{op:'set',path:['rows',9],value:1},{op:'set',path:['rows','0'],value:1},{op:'splice',path:['rows'],start:0,deleteCount:3,items:[]},{op:'splice',path:['rows'],start:-1,deleteCount:0,items:[]},{op:'remove',path:[]},{op:'set',path:[],value:undefined}])assert.throws(()=>applyJournal({rows:[1]},[patch]),/corrupt_recovery_delta/);
|
||||
assert.throws(()=>applyJournal({},Array(10001).fill({op:'set',path:[],value:1})),/corrupt_recovery_delta/);
|
||||
assert.equal(diffJournal(Array(10001).fill(0),Array(10001).fill(1)),null);
|
||||
});
|
||||
// 以可复现序列轮流追加、更新及删除,夹杂历史追加和对象键重排,每一代都通过共同往返与旧根不变检查。
|
||||
test('mixed deterministic updates round-trip exactly across a chain without modifying earlier roots',()=>{
|
||||
// 固定种子的线性同余序列生成器使 150 轮混合增删改可复现;不是随机压力或并发测试。
|
||||
let before={rows:[],nested:{a:1,b:2},history:[]};let seed=71;const random=()=>{seed=(seed*1664525+1013904223)>>>0;return seed;};
|
||||
for(let i=0;i<150;i++){const after=structuredClone(before);if(i%3===0)after.rows.push({id:i,text:String(random())});else if(i%3===1&&after.rows.length)after.rows[random()%after.rows.length].text=String(random());else if(after.rows.length)after.rows.splice(random()%after.rows.length,1);after.history.push(i);if(i%7===0)after.nested={b:i,a:random()};replay(before,after);before=after;}
|
||||
});
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
/**
|
||||
* 恢复值编码与同步/协作式封存测试:JSON 语义、不可变缓存、所有权、深度限制和精确 UTF-8 计数必须同时成立。
|
||||
* 未知 getter、toJSON、Proxy 和可变内建对象可能执行调用者逻辑,不能仅凭 Object.freeze 就跳过捕获。
|
||||
* 协作式路径对安全内部数据按预算让出,对未知回调维持同步捕获顺序;全局探针均需恢复。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {freeze,validJson} from '../../src/lib/canvas-core/values.ts';
|
||||
import {encodeRecoveryValue,sealRecoveryImage,isSealedRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
|
||||
// 用原生 stringify/parse 作为 JSON 规范化参照,包括省略字段、非有限数和自定义 toJSON。
|
||||
const json=value=>JSON.parse(JSON.stringify(value));
|
||||
|
||||
// 以原生 JSON 为参照检查省略、稀疏/不可编码数组项、日期、装箱值、自定义 toJSON 和 own __proto__,输出须合法且冻结。
|
||||
test('recovery encoding preserves JSON value semantics including omitted, sparse, special-key and custom values',()=>{
|
||||
const value={number:-0,infinite:Infinity,missing:undefined,symbol:Symbol('s'),fn(){},array:[,undefined,()=>{},Symbol('s'),NaN],date:new Date('2026-01-01T00:00:00Z'),boxed:new Number(4),custom:{toJSON(key){return{key,text:'custom'};}},['__proto__']:{safe:true}};
|
||||
const encoded=encodeRecoveryValue(value);assert.deepEqual(encoded,json(value));assert.equal(validJson(encoded),true);assert.equal(Object.getPrototypeOf(encoded),Object.prototype);assert.equal(Object.hasOwn(encoded,'__proto__'),true);assert.ok(Object.isFrozen(encoded.array));
|
||||
});
|
||||
// 安全深冻结数据可复用已编码副本;浅冻结外壳包着可变子项时必须重读,编码结果也不能反向修改输入。
|
||||
test('only frozen data subtrees are reused and the encoded image is detached and immutable',()=>{
|
||||
const shared=freeze({items:[{title:'stable',unknown:{retained:true}}]}),left=encodeRecoveryValue({shared}),right=encodeRecoveryValue({shared});assert.equal(left.shared,right.shared);assert.notEqual(left.shared,shared);assert.throws(()=>{left.shared.items[0].title='changed';},TypeError);
|
||||
const mutable={title:'before'},shallow=Object.freeze({mutable});assert.equal(encodeRecoveryValue(shallow).mutable.title,'before');mutable.title='after';assert.equal(encodeRecoveryValue(shallow).mutable.title,'after');assert.equal(encodeRecoveryValue(shallow).mutable===mutable,false);
|
||||
});
|
||||
// 冻结 getter、动态 toJSON、Date 内部状态和继承的数组项仍可能变化;每次编码都应观察当时值。
|
||||
test('frozen getters, custom prototypes and internally mutable objects are evaluated again',()=>{
|
||||
let current='first';const getter=Object.freeze({get title(){return current;}});assert.equal(encodeRecoveryValue(getter).title,'first');current='second';assert.equal(encodeRecoveryValue(getter).title,'second');
|
||||
let enabled=false;const dynamic={value:'ordinary'};Object.defineProperty(dynamic,'toJSON',{get(){return enabled?()=>({changed:true}):undefined;}});Object.freeze(dynamic);assert.deepEqual(encodeRecoveryValue(dynamic),{value:'ordinary'});enabled=true;assert.deepEqual(encodeRecoveryValue(dynamic),{changed:true});
|
||||
const date=Object.freeze(new Date('2026-01-01Z'));const parent=Object.freeze({date});assert.deepEqual(encodeRecoveryValue(parent),json(parent));date.setUTCFullYear(2027);assert.deepEqual(encodeRecoveryValue(parent),json(parent));
|
||||
const prototype=Object.create(Array.prototype);prototype[0]='old';const array=new Array(1);Object.setPrototypeOf(array,prototype);Object.freeze(array);assert.deepEqual(encodeRecoveryValue(array),['old']);prototype[0]='new';assert.deepEqual(encodeRecoveryValue(array),['new']);
|
||||
});
|
||||
// 缓存命中不能绕过深度上限;对象 undefined 字段省略,数组 undefined 项成为 null,循环和 BigInt 都不能正常编码。
|
||||
test('cached subtree height cannot bypass the depth bound and omitted object fields do not count',()=>{
|
||||
// 构造冻结包装层检查总深度;预热 leaf 缓存后仍须把缓存子树高度计入外层限制。
|
||||
const nest=(count,leaf)=>{for(let i=0;i<count;i++)leaf=Object.freeze({child:leaf});return leaf;},leaf=freeze({value:1});encodeRecoveryValue(leaf);assert.equal(validJson(encodeRecoveryValue(nest(63,leaf))),true);assert.throws(()=>encodeRecoveryValue(nest(64,leaf)),/invalid_recovery_payload/);
|
||||
assert.equal(validJson(encodeRecoveryValue(nest(64,Object.freeze({optional:undefined})))),true);assert.throws(()=>encodeRecoveryValue(nest(64,Object.freeze([undefined]))),/invalid_recovery_payload/);
|
||||
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
|
||||
});
|
||||
// 只有工厂封存的对象被识别为 sealed;克隆和浅冻结仿造对象没有所有权,非法修订及格式版本被拒绝。
|
||||
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
|
||||
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(9,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(10,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(11,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(12,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(13,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(14,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(15,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(16,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(17,1,1,{})),true);assert.throws(()=>sealRecoveryImage(18,1,1,{}),/corrupt_recovery_image/);
|
||||
});
|
||||
|
||||
// 对共享子树、Unicode/孤立代理字符和各类规范化输入核对 UTF-8 字节数;克隆 image 不继承内部计数缓存。
|
||||
test('sealed image byte sizes match UTF-8 JSON for reused data and all codec normalization paths',async()=>{
|
||||
const {sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'),shared=freeze({text:'中文😀\ud800\n"\\',items:[null,1,true]}),payloads=[null,'😀',-0,{shared,omitted:undefined,array:[,undefined,NaN]},new Date('2026-01-01Z'),{['__proto__']:'保留'},Object.freeze({get text(){return '动态😀';}})];
|
||||
for(const payload of payloads)for(let revision=0;revision<3;revision++){const image=sealRecoveryImage(17,revision,revision,payload);assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength);assert.equal(sealedRecoveryByteLength(structuredClone(image)),undefined);}
|
||||
});
|
||||
|
||||
|
||||
// 未知 getter 与 toJSON 在首次让出前捕获,之后外部及 yield 回调改值不影响结果;粗时钟的大安全数组仍会主动让出。
|
||||
test('cooperative sealing captures unknown values before yielding and retains exact bytes',async()=>{
|
||||
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
@@ -56,6 +70,7 @@ test('cooperative sealing captures unknown values before yielding and retains ex
|
||||
assert.ok(yields>beforeCoarse);assert.deepEqual(more.payload,large);
|
||||
});
|
||||
|
||||
// 同一未知值的每次出现都独立求值;让出失败、过深数据及非法修订拒绝,不产生可用的半成品 image。
|
||||
test('cooperative preparation evaluates every unsafe occurrence and rejects errors without an image',async()=>{
|
||||
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
@@ -65,6 +80,7 @@ test('cooperative preparation evaluates every unsafe occurrence and rejects erro
|
||||
assert.deepEqual(image.payload,{left:{dynamic:{title:1}},right:{dynamic:{title:2}}});
|
||||
let ticks=0;
|
||||
await assert.rejects(sealRecoveryImageAsync(17,1,1,dataArray(Array.from({length:1000},(_,i)=>i)),{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{throw Error('yield_failed');}}),/yield_failed/);
|
||||
// 异步深度检查使用内部 dataRecord 包装;省略的对象字段和变成 null 的数组项占用深度不同。
|
||||
const nested=(n,leaf)=>{for(let i=0;i<n;i++)leaf=dataRecord({child:leaf});return leaf;};
|
||||
const leaf=freeze({value:1});encodeRecoveryValue(leaf);
|
||||
await assert.rejects(sealRecoveryImageAsync(17,1,1,nested(64,leaf)),/invalid_recovery_payload/);
|
||||
@@ -73,6 +89,7 @@ test('cooperative preparation evaluates every unsafe occurrence and rejects erro
|
||||
await assert.rejects(sealRecoveryImageAsync(17,0,1,{}),/corrupt_recovery_image/);
|
||||
});
|
||||
|
||||
// 临时拦截字符串 stringify 计数,证明标量规范化也分布在预算切片内,并在 finally 恢复全局函数。
|
||||
test('primitive normalization and UTF-8 sizing occur inside the cooperative slice',async()=>{
|
||||
const {dataArray}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
@@ -87,6 +104,7 @@ test('primitive normalization and UTF-8 sizing occur inside the cooperative slic
|
||||
}finally{JSON.stringify=stringify;}
|
||||
});
|
||||
|
||||
// 记录未知 Proxy 的所有 trap,要求异步封存与同步封存的调用次序、次数和输出相同。
|
||||
test('unknown proxies retain the synchronous codec trap order and callback count',async()=>{
|
||||
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
@@ -98,6 +116,7 @@ test('unknown proxies retain the synchronous codec trap order and callback count
|
||||
});
|
||||
|
||||
|
||||
// 同一异步 image 内重复安全容器可复用引用及计数;不安全后代在每次出现时仍独立捕获。
|
||||
test('repeated owned containers reuse facts assembled earlier in the same asynchronous image',async()=>{
|
||||
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
@@ -113,10 +132,12 @@ test('repeated owned containers reuse facts assembled earlier in the same asynch
|
||||
assert.deepEqual(captured.payload,[{getValue:{value:1}},{getValue:{value:2}}]);
|
||||
});
|
||||
|
||||
// 生成首次进入 codec 的大元素数据,让测试能区分第一次规范化与已预热缓存的复用。
|
||||
const ownedRecord=async()=>{
|
||||
const {detachElementData}=await import('../../src/lib/canvas-core/element-data.ts');
|
||||
return detachElementData({id:'large',incarnation:1,structure:'node',ownerCanvasId:'canvas',componentId:'text',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'',rows:Array.from({length:1000},(_,i)=>({index:i,text:'owned-中文😀-'+i}))}});
|
||||
};
|
||||
// 首次处理真实内部拥有的大元素时,首次让出前只编码有限标量;总计仍处理全部字符串并精确计数。
|
||||
test('first-time detached element JSON is normalized inside slices rather than captured in one walk',async()=>{
|
||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const element=await ownedRecord(),stringify=JSON.stringify;let strings=0,first,tick=0;
|
||||
@@ -128,6 +149,7 @@ test('first-time detached element JSON is normalized inside slices rather than c
|
||||
}finally{JSON.stringify=stringify;}
|
||||
});
|
||||
|
||||
// 准入前原型链已有 toJSON getter 时,即使数据由内部拥有也走同步捕获,调用次数与同步路径相同且不让出。
|
||||
test('inherited toJSON accessors at admission keep detached elements on the synchronous capture path',async()=>{
|
||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const left=await ownedRecord(),right=await ownedRecord(),previous=Object.getOwnPropertyDescriptor(Object.prototype,'toJSON');let calls=0,yields=0,tick=0;
|
||||
@@ -139,11 +161,14 @@ test('inherited toJSON accessors at admission keep detached elements on the sync
|
||||
}finally{if(previous)Object.defineProperty(Object.prototype,'toJSON',previous);else delete Object.prototype.toJSON;}
|
||||
});
|
||||
|
||||
// 未知回调在捕获中改变数组 toJSON 时,已捕获 left 保持原值,后续 right 使用新语义,整体与同步 codec 相同。
|
||||
test('unknown callbacks invalidate the detached JSON environment without changing earlier captures',async()=>{
|
||||
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const previous=Object.getOwnPropertyDescriptor(Array.prototype,'toJSON');
|
||||
// 恢复 Array.prototype.toJSON 的原描述符;测试内与 finally 都调用,防止环境探针影响其他用例。
|
||||
const cleanup=()=>{if(previous)Object.defineProperty(Array.prototype,'toJSON',previous);else delete Array.prototype.toJSON;};
|
||||
// left 先捕获,bridge getter 改写数组 toJSON,right 后捕获;同一 image 内顺序变化必须与同步 codec 一致。
|
||||
const make=async()=>dataRecord({left:await ownedRecord(),bridge:{get value(){Object.defineProperty(Array.prototype,'toJSON',{configurable:true,value(){return ['changed-by-callback'];}});return 'bridge';}},right:await ownedRecord()});
|
||||
try{
|
||||
const expected=sealRecoveryImage(17,1,1,await make());cleanup();
|
||||
@@ -154,9 +179,11 @@ test('unknown callbacks invalidate the detached JSON environment without changin
|
||||
}finally{cleanup();}
|
||||
});
|
||||
|
||||
// 完整元素藏在深层包装下时,无论未缓存还是已缓存,均须计入内部深度并拒绝越界。
|
||||
test('deferred detached subtrees enforce the depth bound both cold and after cache reuse',async()=>{
|
||||
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
// 用 61 层内部容器包裹完整元素,使其内部数据深度也触及限制;冷缓存与热缓存都要拒绝。
|
||||
const nest=leaf=>{for(let i=0;i<61;i++)leaf=dataRecord({child:leaf});return leaf;};
|
||||
const cold=await ownedRecord();await assert.rejects(sealRecoveryImageAsync(17,1,1,nest(cold)),/invalid_recovery_payload/);
|
||||
const warm=await ownedRecord();await sealRecoveryImageAsync(17,1,1,warm);
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* 恢复 image 的协作式 JSON 输出测试:分段输出必须与原生 JSON.stringify 的内容及 UTF-8 字节数完全一致。
|
||||
* 让出执行权只改变调度,不得改变捕获值或在 owner 丢失、宿主报错时返回半份成功结果。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {sealRecoveryImage,sealedRecoveryByteLength} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
|
||||
import {serializeRecoveryImage} from '../../src/lib/canvas-runtime/recovery-json.ts';
|
||||
// 每次读取时钟增加 10,迫使序列化跨预算让出;计数器用于证明实际调用了 yieldTask。
|
||||
const options=()=>{let clock=0,yields=0;return{value:{now:()=>clock+=10,yieldTask:async()=>{yields++;}},get yields(){return yields;}};};
|
||||
// 覆盖跨块 emoji、孤立代理字符、转义、整数键顺序、危险名称、非有限数和稀疏数组,对照原生字符串及字节数。
|
||||
test('chunked recovery JSON preserves native byte order, Unicode boundaries, escaping and scalar normalization',async()=>{
|
||||
const values=['a'.repeat(4095)+'👀'+'\n\t"\\'.repeat(4000),'a'.repeat(4095)+'\ud800x\udc00','\u2028\u2029中文'.repeat(5000)],properties=Object.fromEntries(Array.from({length:1800},(_,i)=>[String(1800-i),i%3?values[i%values.length].slice(0,20):null]));Object.defineProperty(properties,'__proto__',{value:{keep:true},enumerable:true});
|
||||
const image=sealRecoveryImage(17,8,5,{values,properties,scalars:[NaN,Infinity,-0,null,true,false],sparse:Array(3)}),scheduling=options(),blob=await serializeRecoveryImage(image,scheduling.value);assert.equal(await blob.text(),JSON.stringify(image));assert.equal(blob.size,new TextEncoder().encode(JSON.stringify(image)).byteLength);assert.equal(blob.size,sealedRecoveryByteLength(image));assert.ok(scheduling.yields>0);assert.deepEqual(JSON.parse(await blob.text()),JSON.parse(JSON.stringify(image)));
|
||||
});
|
||||
// 封存后重复序列化不重新读调用者 getter;外部未拥有 image 则先防御性捕获,随后变更不能影响正在输出的值。
|
||||
test('small owned subtree reuse does not reread caller getters or infer ownership from external freezing',async()=>{
|
||||
let reads=0;const caller=Object.freeze({get text(){reads++;return'captured';}}),image=sealRecoveryImage(1,0,0,{rows:Array.from({length:1000},(_,i)=>({id:i,value:caller}))}),before=reads,a=await serializeRecoveryImage(image,options().value),b=await serializeRecoveryImage(image,options().value);assert.equal(reads,before);assert.equal(await a.text(),JSON.stringify(image));assert.equal(await b.text(),await a.text());
|
||||
const external={formatVersion:1,rootRevision:0,localRevision:0,payload:{nested:'external'}};const pending=serializeRecoveryImage(external);external.payload.nested='late';assert.equal(JSON.parse(await(await pending).text()).payload.nested,'external');
|
||||
});
|
||||
// 粗时钟下大 image 仍须让出;check 检测 owner 丢失或 yieldTask 抛错时,以原错误拒绝而非返回部分 Blob。
|
||||
test('coarse clocks yield for large images and a lost owner or host failure cannot return partial bytes',async()=>{
|
||||
const image=sealRecoveryImage(1,1,1,{rows:Array.from({length:14000},(_,i)=>({id:i,text:'x'.repeat(100)}))});let yields=0;const blob=await serializeRecoveryImage(image,{now:()=>0,yieldTask:async()=>{yields++;}});assert.ok(yields>0);assert.equal(await blob.text(),JSON.stringify(image));
|
||||
const failure=Error('owner lost');let gone=false,clock=0;await assert.rejects(serializeRecoveryImage(image,{now:()=>clock+=10,yieldTask:async()=>{gone=true;},check(){if(gone)throw failure;}}),error=>error===failure);clock=0;await assert.rejects(serializeRecoveryImage(image,{now:()=>clock+=10,yieldTask:async()=>{throw failure;}}),error=>error===failure);
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
/**
|
||||
* 恢复编码缓存的所有权边界测试。真正由内部拥有的不可变文档/表可复用编码,外部浅冻结对象不能据此获得同样信任。
|
||||
* 通过暂时拦截 Map 遍历检查重复工作,并比较旧 image 的内容和引用;拦截均在 finally 恢复。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,applyChanges} from '../../src/lib/canvas-core/document.ts';
|
||||
import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts';
|
||||
import {encodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
// 节点工厂和双节点基线;未改动的 B 可用来检查局部编辑后编码是否复用。
|
||||
const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}),doc=()=>createDocument('root',[{id:'root',parent:null}],[node('A'),node('B')]);
|
||||
// 组装 encodeRecovery 需要的最小状态根,默认 acknowledged 与 working 共用文档,其他运行时表为空。
|
||||
const snapshot=(document,rootRevision=0)=>({rootRevision,revision:0,document,operations:new Map(),history:{undo:[],redo:[],entries:new Map()},runtime:{acknowledged:document,versions:new Map(),runs:new Map(),resources:new Map(),resourcePins:new Map(),receipts:new Map(),conflicts:new Map(),ledger:createLedger().getSnapshot()}});
|
||||
// 仅 rootRevision 变化时不再枚举文档;改动 A 后新 image 更新 A、复用 B,并保持先前 image 不变。
|
||||
test('metadata-only roots reuse the document encoding and preserve prior images through a later edit',()=>{
|
||||
const document=doc(),one=encodeRecovery(snapshot(document));let two;const values=Map.prototype.values;try{Map.prototype.values=function(){if(this.size===2)throw Error('metadata root enumerated document records');return values.call(this);};two=encodeRecovery(snapshot(document,1));}finally{Map.prototype.values=values;}assert.strictEqual(one.payload.document,two.payload.document);assert.strictEqual(one.payload.document,one.payload.runtime.acknowledged);const changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'',after:'new'}]),three=encodeRecovery(snapshot(changed,2));assert.equal(three.payload.document.elements[0].data.text,'new');assert.equal(one.payload.document.elements[0].data.text,'');assert.strictEqual(three.payload.document.elements[1],one.payload.document.elements[1]);assert.strictEqual(three.payload.document,encodeRecovery(snapshot(changed,3)).payload.document);
|
||||
});
|
||||
// 外部 envelope 即使冻结,内部 Map 成员与元素数据仍可能变;每次编码重新捕获,既不冻结调用者也不污染旧 image。
|
||||
test('unowned document envelopes reread mutable map membership and nested data without freezing callers',()=>{
|
||||
const base=doc(),mutable=node('mutable'),elements=new Map([['mutable',mutable]]),document=Object.freeze({...base,elements}),one=encodeRecovery(snapshot(document));mutable.data.text='changed';elements.set('other',node('other'));const two=encodeRecovery(snapshot(document,1));assert.equal(two.payload.document.elements.length,2);assert.equal(two.payload.document.elements[0].data.text,'changed');assert.equal(one.payload.document.elements.length,1);assert.equal(one.payload.document.elements[0].data.text,'');assert.equal(Object.isFrozen(mutable),false);const lookalike=Object.freeze({...base});assert.notStrictEqual(encodeRecovery(snapshot(lookalike)).payload.document,encodeRecovery(snapshot(lookalike,1)).payload.document);
|
||||
});
|
||||
// applyChanges 接管表成员关系并不证明外部元素深不可变:源表后来加项不进入新表,嵌套数据变化仍须重新捕获。
|
||||
test('factory table ownership does not imply deep immutability of values inherited from an external document',()=>{
|
||||
const base=doc(),mutable=node('external'),source={...base,elements:new Map([['external',mutable]])},document=applyChanges(source,[]),one=encodeRecovery(snapshot(document));mutable.data.text='later';source.elements.set('new',node('new'));const two=encodeRecovery(snapshot(document,1));assert.equal(two.payload.document.elements.length,1);assert.equal(two.payload.document.elements[0].data.text,'later');assert.equal(one.payload.document.elements[0].data.text,'');assert.equal(Object.isFrozen(mutable),false);
|
||||
});
|
||||
|
||||
import {readMap,freeze} from '../../src/lib/canvas-core/values.ts';
|
||||
import * as publicCore from '../../src/lib/canvas-core/index.ts';
|
||||
// 内部表编码可跨元数据代次复用,新增表项只生成新成员集合并复用安全旧值;内部 provenance 也不能泄漏为公共 API。
|
||||
test('metadata roots reuse owned table entry encodings while new tables retain old image membership',()=>{
|
||||
assert.equal(Object.hasOwn(publicCore,'isOwnedMapView'),false,'provenance is an internal codec optimization');
|
||||
const state=snapshot(doc()),entries=readMap(new Map([['one',freeze({text:'first'})],['two',freeze({text:'second'})]]));state.operations=entries;state.history.entries=entries;state.runtime.receipts=entries;const one=encodeRecovery(state),iterate=Map.prototype[Symbol.iterator];let two;
|
||||
@@ -24,6 +34,7 @@ test('metadata roots reuse owned table entry encodings while new tables retain o
|
||||
assert.strictEqual(two.payload.operations,one.payload.operations);assert.strictEqual(two.payload.history.entries,one.payload.history.entries);assert.strictEqual(two.payload.runtime.receipts,one.payload.runtime.receipts);assert.strictEqual(one.payload.operations,one.payload.runtime.receipts);
|
||||
const changed=readMap(new Map([...entries,['three',freeze({text:'third'})]])),three=encodeRecovery({...state,rootRevision:2,operations:changed});assert.equal(three.payload.operations.length,3);assert.equal(one.payload.operations.length,2);assert.strictEqual(three.payload.operations[0][1],one.payload.operations[0][1]);
|
||||
});
|
||||
// 冻结外部 Map 不冻结成员或阻止 set;内部表里的浅冻结值也可能含可变子项,两种情况都必须重读并保留旧快照。
|
||||
test('external maps and immutable-looking maps reread membership; owned tables still reread mutable nested values',()=>{
|
||||
const state=snapshot(doc()),external=Object.freeze(new Map([['one',{text:'first'}]]));state.runtime.receipts=external;const one=encodeRecovery(state);external.get('one').text='second';external.set('two',{text:'other'});const two=encodeRecovery({...state,rootRevision:1});assert.deepEqual(two.payload.runtime.receipts,[['one',{text:'second'}],['two',{text:'other'}]]);assert.equal(one.payload.runtime.receipts.length,1);assert.equal(one.payload.runtime.receipts[0][1].text,'first');assert.equal(Object.isFrozen(external.get('one')),false);
|
||||
const mutable={text:'first'},shallow=Object.freeze({mutable}),owned=readMap(new Map([['one',shallow]]));state.runtime.receipts=owned;const three=encodeRecovery({...state,rootRevision:2});mutable.text='changed';const four=encodeRecovery({...state,rootRevision:3});assert.equal(three.payload.runtime.receipts[0][1].mutable.text,'first');assert.equal(four.payload.runtime.receipts[0][1].mutable.text,'changed');assert.equal(Object.isFrozen(mutable),false);
|
||||
|
||||
Reference in New Issue
Block a user