Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc0f25cad0 | |||
| 7de9a3ea1d | |||
| 3cda36b685 | |||
| 1a0d9cdc8a | |||
| 53905a4868 | |||
| 298f4b36c0 | |||
| c426e94b58 | |||
| 330d8d6e34 | |||
| ea102b9949 | |||
| efcec4d603 | |||
| e75b9ed5e4 | |||
| 74748d6e23 | |||
| 3da19e288a | |||
| 47e318682e | |||
| 9946142d5e | |||
| 1c832988a2 | |||
| 95e8a57019 | |||
| 6b74629d98 | |||
| 33f5c076f6 | |||
| bcf810998d | |||
| fb737eeb0b |
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 独立SDK的第二宿主示例与可执行断言:用自定义counter组件串起命令、事务拒绝、历史、手势、深复制、重开及投影命中。
|
||||||
|
* 包导入只使用core/runtime/bindings/service,验证这条路径不需要React、DOM或Next;示例需在已构建SDK包的解析环境执行。
|
||||||
|
* 存储与协议transport都是进程内模型,用于演示port契约,不实现生产数据库、身份鉴权、跨进程锁或崩溃持久化。
|
||||||
|
*/
|
||||||
/** A second host: no DOM, React, Next, React Flow or network service required.
|
/** A second host: no DOM, React, Next, React Flow or network service required.
|
||||||
* Storage and protocol-2 transport below are in-memory examples, not a disk or
|
* Storage and protocol-2 transport below are in-memory examples, not a disk or
|
||||||
* production server implementation. Replace them with your host's real ports.
|
* production server implementation. Replace them with your host's real ports.
|
||||||
@@ -9,15 +14,24 @@ import { openProjectEngine } from '@endoleap/headless-canvas/runtime';
|
|||||||
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
|
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
|
||||||
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
|
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
|
||||||
|
|
||||||
|
// 示例固定账户/工作区/项目作用域;真实宿主应从已验证身份和项目上下文建立隔离范围。
|
||||||
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'};
|
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'};
|
||||||
|
// 内置组件之外注册counter:count要求非负安全整数,能力与矩形几何显式声明,资源与复制引用列表为空。
|
||||||
const registry=createRegistry([...builtinComponents(),defineComponent({id:'example.counter',dataVersion:1,structure:'node',validate:data=>typeof data.title==='string'&&Number.isSafeInteger(data.count)&&data.count>=0,features:[editable(),movable(),resizable()],geometry:rectangleGeometry,resourceFields:[],copy:{references:[],omit:[]}})]);
|
const registry=createRegistry([...builtinComponents(),defineComponent({id:'example.counter',dataVersion:1,structure:'node',validate:data=>typeof data.title==='string'&&Number.isSafeInteger(data.count)&&data.count>=0,features:[editable(),movable(),resizable()],geometry:rectangleGeometry,resourceFields:[],copy:{references:[],omit:[]}})]);
|
||||||
|
// 空项目只有根canvas;server/version模拟服务端基线,image模拟本地恢复镜像,两者职责分开。
|
||||||
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
|
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
|
||||||
let image=null,leader,fence=0,server=original,version=1;
|
let image=null,leader,fence=0,server=original,version=1;
|
||||||
|
// files保存示例字节,requests按requestId保存原body和回执,供相同请求重试查询;均在进程结束后丢失。
|
||||||
const files=new Map(),requests=new Map();
|
const files=new Map(),requests=new Map();
|
||||||
|
/** 通过对象身份及active校验本地leader句柄;仅演示单进程fence,不是数据库条件写。 */
|
||||||
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
|
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
|
||||||
|
// StoragePort示例:claim取得独占句柄,recover读克隆镜像,commit先准备镜像/文件再一次同步发布。
|
||||||
const storage={
|
const storage={
|
||||||
|
/** 只允许一个活跃leader并递增fence;release幂等失效该句柄,不清除已提交镜像。 */
|
||||||
async claim(requested){assert.equal(leader,undefined);assert.deepEqual(requested,scope);let active=true;const handle={scope:requested,fence:++fence,get active(){return active;},async release(){active=false;if(leader===handle)leader=undefined;}};leader=handle;return handle;},
|
async claim(requested){assert.equal(leader,undefined);assert.deepEqual(requested,scope);let active=true;const handle={scope:requested,fence:++fence,get active(){return active;},async release(){active=false;if(leader===handle)leader=undefined;}};leader=handle;return handle;},
|
||||||
|
/** 返回镜像克隆,调用方不能直接修改存储事实;空image代表首次打开。 */
|
||||||
async recover(handle){check(handle);return structuredClone(image);},
|
async recover(handle){check(handle);return structuredClone(image);},
|
||||||
|
/** 先检查文件新增不覆盖和删除列表,准备完成才发布;返回实际镜像rootRevision作为本地确认。 */
|
||||||
async commit(handle,batch){
|
async commit(handle,batch){
|
||||||
check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files);
|
check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files);
|
||||||
for(const file of batch.files??[]){assert.equal(nextFiles.has(file.id),false);nextFiles.set(file.id,file.bytes);}
|
for(const file of batch.files??[]){assert.equal(nextFiles.has(file.id),false);nextFiles.set(file.id,file.bytes);}
|
||||||
@@ -25,11 +39,15 @@ const storage={
|
|||||||
// Prepare before publishing. This is atomic in this process, not crash-durable storage.
|
// Prepare before publishing. This is atomic in this process, not crash-durable storage.
|
||||||
image=cut;files.clear();for(const entry of nextFiles)files.set(...entry);return{durableRootRevision:cut.rootRevision};
|
image=cut;files.clear();for(const entry of nextFiles)files.set(...entry);return{durableRootRevision:cut.rootRevision};
|
||||||
},
|
},
|
||||||
|
/** 文件读取也校验leader;close断言所有engine已释放领导权,不负责清理生产存储。 */
|
||||||
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
|
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
|
||||||
};
|
};
|
||||||
|
/** 将模拟server的Map转成协议模型数组,带当前版本与墓碑;不把整个engine状态冒充服务端模型。 */
|
||||||
const model=()=>({documentFormatVersion:1,projectId:scope.projectId,complete:true,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[scope.projectId]:version},removedCanvasIds:[]});
|
const model=()=>({documentFormatVersion:1,projectId:scope.projectId,complete:true,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[scope.projectId]:version},removedCanvasIds:[]});
|
||||||
|
/** 最小协议2传输适配:模型GET、请求回执GET及操作POST;未实现任意Foundry端点或HTTP鉴权。 */
|
||||||
const transport=async input=>{
|
const transport=async input=>{
|
||||||
input.signal?.throwIfAborted();
|
input.signal?.throwIfAborted();
|
||||||
|
// 请求身份查询没命中时返回unknown,不伪造确定未应用回执;本示例记录不跨进程保存。
|
||||||
if(input.method==='GET'){
|
if(input.method==='GET'){
|
||||||
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
|
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
|
||||||
const id=input.path.split('/').at(-1),known=requests.get(id);
|
const id=input.path.split('/').at(-1),known=requests.get(id);
|
||||||
@@ -37,32 +55,45 @@ const transport=async input=>{
|
|||||||
}
|
}
|
||||||
const body=JSON.parse(input.body),known=requests.get(input.requestId);
|
const body=JSON.parse(input.body),known=requests.get(input.requestId);
|
||||||
assert.equal(body.protocol_version,2);assert.equal(body.request_id,input.requestId);
|
assert.equal(body.protocol_version,2);assert.equal(body.request_id,input.requestId);
|
||||||
|
// 已有requestId只能复用完全相同的body并返回原回执,防止幂等键承载两种意图。
|
||||||
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
|
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
|
||||||
|
// 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。
|
||||||
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
|
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
|
||||||
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
|
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
|
||||||
|
// 回执逐一覆盖operations逻辑ID,并携complete=false的模型切片;记录原body方便重试校验。
|
||||||
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{...model(),complete:false}};
|
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{...model(),complete:false}};
|
||||||
requests.set(input.requestId,{body:input.body,receipt});return{status:200,body:receipt};
|
requests.set(input.requestId,{body:input.body,receipt});return{status:200,body:receipt};
|
||||||
};
|
};
|
||||||
|
// service把transport包成runtime持久化接口;open示例总以初始模型输入,由StoragePort恢复已提交本地状态。
|
||||||
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
|
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
|
||||||
|
/** 新建engine注入scope/registry/storage/persistence与ID生成器,所有宿主依赖显式传入。 */
|
||||||
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
|
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
|
||||||
let engine=await open();
|
let engine=await open();
|
||||||
try{
|
try{
|
||||||
|
// 先创建一个counter并settle,后续断言分别验证本地发布及模拟server状态;元素带独立incarnation。
|
||||||
const target={id:randomUUID(),incarnation:1};
|
const target={id:randomUUID(),incarnation:1};
|
||||||
const element={...target,ownerCanvasId:scope.projectId,componentId:'example.counter',dataVersion:1,structure:'node',data:{title:'Portable counter',count:1},geometry:{x:0,y:0,width:160,height:80,rotation:0}};
|
const element={...target,ownerCanvasId:scope.projectId,componentId:'example.counter',dataVersion:1,structure:'node',data:{title:'Portable counter',count:1},geometry:{x:0,y:0,width:160,height:80,rotation:0}};
|
||||||
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
|
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
|
||||||
|
// 非法事务先移动再写负count,最终必须整体rejected且根对象不变,不能泄露事务中间结果。
|
||||||
const before=engine.getSnapshot();
|
const before=engine.getSnapshot();
|
||||||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-1}});}).status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-1}});}).status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
||||||
|
// 合法事务将移动和增量组合成一个历史单元;undo/redo之后均等待同步,再检查server的count。
|
||||||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:2}});},{label:'Move and increment'}).status,'applied');await engine.settle();
|
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:2}});},{label:'Move and increment'}).status,'applied');await engine.settle();
|
||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,1);
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,1);
|
||||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,2);
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,2);
|
||||||
|
// 临时手势的有效几何可变化,但cancel后文档仍x=32且请求数不增加,证明预览未被当持久编辑发送。
|
||||||
engine.sessions.attach('headless-view');const writes=requests.size;
|
engine.sessions.attach('headless-view');const writes=requests.size;
|
||||||
assert.equal(engine.sessions.begin('headless-view',[target]).enabled,true);engine.sessions.update('headless-view',target,{x:90});assert.equal(engine.sessions.effectiveGeometry(target.id).x,90);engine.sessions.cancel('example-cancel');await engine.settle();assert.equal(requests.size,writes);assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,32);
|
assert.equal(engine.sessions.begin('headless-view',[target]).enabled,true);engine.sessions.update('headless-view',target,{x:90});assert.equal(engine.sessions.effectiveGeometry(target.id).x,90);engine.sessions.cancel('example-cancel');await engine.settle();assert.equal(requests.size,writes);assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,32);
|
||||||
|
// 深复制走imports任务并等待settle,检查任务完成及本地/模拟服务端元素数;随后验证service模型可读。
|
||||||
const copyId=await engine.imports.duplicate([target],{worldOffset:{x:24,y:24}});await engine.settle();assert.equal(engine.imports.getJobs().get(copyId).status,'completed',JSON.stringify(engine.imports.getJobs().get(copyId)));assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(server.elements.size,2);
|
const copyId=await engine.imports.duplicate([target],{worldOffset:{x:24,y:24}});await engine.settle();assert.equal(engine.imports.getJobs().get(copyId).status,'completed',JSON.stringify(engine.imports.getJobs().get(copyId)));assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(server.elements.size,2);
|
||||||
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2);
|
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2);
|
||||||
|
// 关闭再打开依赖image恢复内容、历史和ledger;视图属于临时session,重开后不恢复。
|
||||||
const snapshot=engine.getSnapshot(),undoDepth=snapshot.history.undo.length;await engine.close();engine=await open();await engine.settle();
|
const snapshot=engine.getSnapshot(),undoDepth=snapshot.history.undo.length;await engine.close();engine=await open();await engine.settle();
|
||||||
assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(target.id).data.count,2);assert.equal(engine.getSnapshot().history.undo.length,undoDepth);assert.equal(engine.sessions.getSnapshot().views.size,0);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));
|
assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(target.id).data.count,2);assert.equal(engine.getSnapshot().history.undo.length,undoDepth);assert.equal(engine.sessions.getSnapshot().views.size,0);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));
|
||||||
|
// 自定义renderer只声明记录投影,不引入UI组件;配查询与几何缓存生成场景,再验证纯数据命中结果。
|
||||||
const current=engine.getSnapshot(),queries=engine.editor.getQueries(),renderers=createRendererCatalog([...builtinRendererDefinitions,{componentId:'example.counter',dataVersion:1,structure:'node',rendererId:'counter',projection:'record-v1'}]);
|
const current=engine.getSnapshot(),queries=engine.editor.getQueries(),renderers=createRendererCatalog([...builtinRendererDefinitions,{componentId:'example.counter',dataVersion:1,structure:'node',rendererId:'counter',projection:'record-v1'}]);
|
||||||
const projection=projectCanvas(current.document,scope.projectId,registry,new Set(),()=>undefined,null,renderers,{queries,geometry:createGeometryProjectionCache()});assert.equal(projection.nodes.length,2);assert.ok(projection.nodes.every(node=>node.type==='counter'&&node.data.__headlessRecord));
|
const projection=projectCanvas(current.document,scope.projectId,registry,new Set(),()=>undefined,null,renderers,{queries,geometry:createGeometryProjectionCache()});assert.equal(projection.nodes.length,2);assert.ok(projection.nodes.every(node=>node.type==='counter'&&node.data.__headlessRecord));
|
||||||
const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id));
|
const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id));
|
||||||
|
// 输出可机器读取的结果摘要;finally即使断言失败也关闭engine并验证存储领导权已释放。
|
||||||
console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
|
console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
|
||||||
}finally{await engine.close();storage.close();}
|
}finally{await engine.close();storage.close();}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Next应用配置。为webpack与Turbopack安装相同ReactFlow编译扩展,并给带内容摘要的图标分片设置长期缓存。
|
||||||
|
* 此处只配置应用编译/响应头,不改变headless SDK导出或服务端数据协议。
|
||||||
|
*/
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
/** StrictMode显式开启开发检查,poweredByHeader关闭Next标识响应头;其余保持框架默认行为。 */
|
||||||
const config: NextConfig = {
|
const config: NextConfig = {
|
||||||
reactStrictMode: true, poweredByHeader: false,
|
reactStrictMode: true, poweredByHeader: false,
|
||||||
|
/** 在现有规则后追加匹配ReactFlow ESM入口的loader,再返回原配置对象,保留框架其它规则。 */
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
|
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
|
||||||
@@ -8,6 +14,7 @@ const config: NextConfig = {
|
|||||||
});
|
});
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
// Turbopack使用条件路径匹配同一个入口;单独配置以免切换构建器时丢失保留节点扩展。
|
||||||
turbopack: {
|
turbopack: {
|
||||||
rules: {
|
rules: {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -16,6 +23,8 @@ const config: NextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/** 只给版本目录内带12位十六进制摘要的JSON分片配置一年immutable缓存,文件内容变化应生成新文件名。
|
||||||
|
* 不对图标目录manifest、页面或全部JSON一概设置immutable。 */
|
||||||
async headers() {
|
async headers() {
|
||||||
return [{
|
return [{
|
||||||
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
|
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* CSS构建入口:启用项目安装的Tailwind PostCSS插件,供Next样式处理链读取。
|
||||||
|
* 插件选项为空对象表示使用默认配置;这里不执行CSS生成,也不在导入时启动构建。
|
||||||
|
*/
|
||||||
export default { plugins: { "@tailwindcss/postcss": {} } };
|
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 独立headless SDK的产物生成脚本。先清理.headless-dist,再用tsconfig.headless.json编译四个库目录,修正声明与Worker引用。
|
||||||
|
* 最后复制包清单、无UI宿主示例和验收文档;失败立即抛出,不把半成品报告成可用包。执行会删除并重建该产物目录。
|
||||||
|
*/
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
// 路径基于脚本URL而非当前终端目录;tsc仍显式以仓库根作为cwd解析项目依赖。
|
||||||
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
|
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
|
||||||
await rm(output,{recursive:true,force:true});
|
await rm(output,{recursive:true,force:true});
|
||||||
|
// 同步等待TypeScript生成ESM及声明;继承终端输出,编译失败直接中止后续处理。
|
||||||
execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'});
|
execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'});
|
||||||
|
/** 递归只处理.d.ts的相对模块说明符,将源.ts后缀改为.js;普通schema/data字符串不做全局替换。
|
||||||
|
* 这是针对当前输出格式的正则修补,不是任意TypeScript语法的完整重写器。 */
|
||||||
async function declarations(directory){
|
async function declarations(directory){
|
||||||
for(const entry of await readdir(directory,{withFileTypes:true})){
|
for(const entry of await readdir(directory,{withFileTypes:true})){
|
||||||
const path=join(directory,entry.name);
|
const path=join(directory,entry.name);
|
||||||
@@ -19,12 +27,15 @@ async function declarations(directory){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 原生编译器处理运行时import后缀,声明文件仍需此补充;完成后再修URL字面量里的Worker源后缀。
|
||||||
await declarations(join(output,'modules'));
|
await declarations(join(output,'modules'));
|
||||||
|
// 两个浏览器适配器各应含一个预期Worker URL;要求目标.js文件存在并且源字符串只出现一次才改写。
|
||||||
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){
|
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){
|
||||||
const path=join(output,'modules/canvas-runtime',`${module}.js`),source=await readFile(path,'utf8'),before=`./${worker}.ts`;
|
const path=join(output,'modules/canvas-runtime',`${module}.js`),source=await readFile(path,'utf8'),before=`./${worker}.ts`;
|
||||||
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
|
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
|
||||||
await readFile(join(output,'modules/canvas-runtime',`${worker}.js`));
|
await readFile(join(output,'modules/canvas-runtime',`${worker}.js`));
|
||||||
await writeFile(path,source.replace(before,`./${worker}.js`));
|
await writeFile(path,source.replace(before,`./${worker}.js`));
|
||||||
}
|
}
|
||||||
|
// 产物保留与headless/package.json的exports/files配套的模块布局;这里复制清单,不执行npm发布或包验收测试。
|
||||||
for(const name of ['package.json','consumer.mjs','README.md','STEP4_ACCEPTANCE.md','SERVICE_ACCEPTANCE.md','FINAL_ACCEPTANCE.md'])await cp(join(root,'headless',name),join(output,name));
|
for(const name of ['package.json','consumer.mjs','README.md','STEP4_ACCEPTANCE.md','SERVICE_ACCEPTANCE.md','FINAL_ACCEPTANCE.md'])await cp(join(root,'headless',name),join(output,name));
|
||||||
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
|
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 图标资源生成时使用的中文标注表。作为静态元数据输入给generate-canvas-icons,不在运行时逐个请求翻译。
|
||||||
|
*/
|
||||||
|
/** 完整PascalCase图标名到中文标签的人工覆盖表;未列出的图标由生成脚本回退slug拆词名称。 */
|
||||||
export const chinese = {
|
export const chinese = {
|
||||||
"Star": "星星",
|
"Star": "星星",
|
||||||
"Heart": "爱心",
|
"Heart": "爱心",
|
||||||
@@ -45,4 +49,5 @@ export const chinese = {
|
|||||||
"Triangle": "三角形",
|
"Triangle": "三角形",
|
||||||
"Diamond": "菱形"
|
"Diamond": "菱形"
|
||||||
};
|
};
|
||||||
|
/** slug单词到中文检索词,生成时按连字符拆分并拼接;一个词可扩展多个搜索同义词。 */
|
||||||
export const keywords = { arrow: "箭头 方向", user: "用户 人物", file: "文件 文档", folder: "文件夹 目录", heart: "爱心 喜欢", calendar: "日历 日期", clock: "时间 时钟", image: "图片 图像", camera: "相机 摄影", video: "视频 电影", music: "音乐 音频", cloud: "云 天气", sun: "太阳 天气", moon: "月亮 夜晚", circle: "圆 圆形", square: "方形 正方形", triangle: "三角形", building: "建筑 公司", book: "书 阅读", code: "代码 编程", chart: "图表 数据", mail: "邮件 信封", message: "消息 对话", map: "地图 位置", search: "搜索 查找", settings: "设置", lock: "锁 安全", key: "钥匙 密钥", star: "星星 收藏", check: "确认 完成", chevron: "箭头 展开", laptop: "电脑 笔记本", monitor: "屏幕 显示器", smartphone: "手机", shopping: "购物 商店", dollar: "货币 金钱", euro: "货币 欧元", zodiac: "星座", tree: "树 植物", flower: "花 植物", leaf: "叶子 植物", dog: "狗 动物", cat: "猫 动物", fish: "鱼 动物", coffee: "咖啡 饮料", food: "食物", smile: "表情 笑脸", angry: "表情 生气", frown: "表情 难过", paint: "绘画 颜色", palette: "调色板 颜色", text: "文本 文字", type: "字体 文字", grip: "拖动", move: "移动", zoom: "缩放", eye: "眼睛 显示", accessibility: "无障碍", audio: "音频 声音", battery: "电池", network: "网络 连接", wifi: "无线 网络", globe: "地球 全球", bell: "铃铛 通知", badge: "徽章", shield: "盾牌 安全", pencil: "铅笔 编辑", pen: "笔 写作", trash: "垃圾 删除", undo: "撤销", redo: "重做", rocket: "火箭", sparkles: "闪光 灵感", lightbulb: "灯泡 灵感" };
|
export const keywords = { arrow: "箭头 方向", user: "用户 人物", file: "文件 文档", folder: "文件夹 目录", heart: "爱心 喜欢", calendar: "日历 日期", clock: "时间 时钟", image: "图片 图像", camera: "相机 摄影", video: "视频 电影", music: "音乐 音频", cloud: "云 天气", sun: "太阳 天气", moon: "月亮 夜晚", circle: "圆 圆形", square: "方形 正方形", triangle: "三角形", building: "建筑 公司", book: "书 阅读", code: "代码 编程", chart: "图表 数据", mail: "邮件 信封", message: "消息 对话", map: "地图 位置", search: "搜索 查找", settings: "设置", lock: "锁 安全", key: "钥匙 密钥", star: "星星 收藏", check: "确认 完成", chevron: "箭头 展开", laptop: "电脑 笔记本", monitor: "屏幕 显示器", smartphone: "手机", shopping: "购物 商店", dollar: "货币 金钱", euro: "货币 欧元", zodiac: "星座", tree: "树 植物", flower: "花 植物", leaf: "叶子 植物", dog: "狗 动物", cat: "猫 动物", fish: "鱼 动物", coffee: "咖啡 饮料", food: "食物", smile: "表情 笑脸", angry: "表情 生气", frown: "表情 难过", paint: "绘画 颜色", palette: "调色板 颜色", text: "文本 文字", type: "字体 文字", grip: "拖动", move: "移动", zoom: "缩放", eye: "眼睛 显示", accessibility: "无障碍", audio: "音频 声音", battery: "电池", network: "网络 连接", wifi: "无线 网络", globe: "地球 全球", bell: "铃铛 通知", badge: "徽章", shield: "盾牌 安全", pencil: "铅笔 编辑", pen: "笔 写作", trash: "垃圾 删除", undo: "撤销", redo: "重做", rocket: "火箭", sparkles: "闪光 灵感", lightbulb: "灯泡 灵感" };
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 重建画布图标目录与路径分片的离线资源脚本。使用已安装Lucide与指定本地Tabler包,写目录JSON、摘要文件名分片和许可证。
|
||||||
|
* 版本检查固定输入格式;不会联网下载包,也不删除旧摘要分片,调用者需按脚本注释准备输入并从仓库根执行。
|
||||||
|
*/
|
||||||
// Reproduce with installed lucide-react 1.69.0 and an extracted @tabler/icons 3.50.0
|
// Reproduce with installed lucide-react 1.69.0 and an extracted @tabler/icons 3.50.0
|
||||||
// tarball: node scripts/generate-canvas-icons.mjs /path/to/tabler/package
|
// tarball: node scripts/generate-canvas-icons.mjs /path/to/tabler/package
|
||||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
@@ -6,13 +10,16 @@ import { resolve } from "node:path";
|
|||||||
import dynamicIconImports from "lucide-react/dynamicIconImports";
|
import dynamicIconImports from "lucide-react/dynamicIconImports";
|
||||||
import { chinese, keywords } from "./canvas-icon-labels.mjs";
|
import { chinese, keywords } from "./canvas-icon-labels.mjs";
|
||||||
|
|
||||||
|
// 命令行参数是已解压Tabler包目录;同时校验两套依赖版本,防止升级后未经审查改变SVG/导出结构。
|
||||||
const tablerDir = process.argv[2];
|
const tablerDir = process.argv[2];
|
||||||
if (!tablerDir) throw new Error("Pass the extracted @tabler/icons 3.50.0 directory");
|
if (!tablerDir) throw new Error("Pass the extracted @tabler/icons 3.50.0 directory");
|
||||||
const tablerPackage = JSON.parse(await readFile(resolve(tablerDir, "package.json"), "utf8"));
|
const tablerPackage = JSON.parse(await readFile(resolve(tablerDir, "package.json"), "utf8"));
|
||||||
if (tablerPackage.version !== "3.50.0") throw new Error("Expected @tabler/icons 3.50.0");
|
if (tablerPackage.version !== "3.50.0") throw new Error("Expected @tabler/icons 3.50.0");
|
||||||
const lucidePackage = JSON.parse(await readFile("node_modules/lucide-react/package.json", "utf8"));
|
const lucidePackage = JSON.parse(await readFile("node_modules/lucide-react/package.json", "utf8"));
|
||||||
if (lucidePackage.version !== "1.69.0") throw new Error("Review generated assets when upgrading Lucide");
|
if (lucidePackage.version !== "1.69.0") throw new Error("Review generated assets when upgrading Lucide");
|
||||||
|
/** slug按连字符转PascalCase;首字符非字母时加Icon前缀,形成稳定的目录ID。 */
|
||||||
const pascal = (slug) => { const value = slug.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(""); return /^[A-Z]/.test(value) ? value : "Icon" + value; };
|
const pascal = (slug) => { const value = slug.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(""); return /^[A-Z]/.test(value) ? value : "Icon" + value; };
|
||||||
|
/** 用slug正则派生分类,一个图标可匹配多个;未匹配的记录categories为空,UI可归“其他”。 */
|
||||||
const categories = [
|
const categories = [
|
||||||
["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/],
|
["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/],
|
||||||
["people", /user|person|baby|smile|frown|angry|laugh|heart|hand|thumb|accessibility/],
|
["people", /user|person|baby|smile|frown|angry|laugh|heart|hand|thumb|accessibility/],
|
||||||
@@ -24,17 +31,21 @@ const categories = [
|
|||||||
["travel", /map|pin|compass|globe|plane|train|car|bus|bike|ship|boat|building|house|home|tent|hotel|lighthouse|rocket|ufo/],
|
["travel", /map|pin|compass|globe|plane|train|car|bus|bike|ship|boat|building|house|home|tent|hotel|lighthouse|rocket|ufo/],
|
||||||
["shapes", /circle|square|triangle|hexagon|pentagon|octagon|diamond|star|shape|cube|sphere|box/],
|
["shapes", /circle|square|triangle|hexagon|pentagon|octagon|diamond|star|shape|cube|sphere|box/],
|
||||||
];
|
];
|
||||||
|
/** Lucide常用集合用于默认分类;Tabler试用集稍后全部标记common。 */
|
||||||
const common = new Set(["Star", "Heart", "Lightbulb", "Sparkles", "Zap", "Target", "Check", "Smile", "Coffee", "Leaf", "Sun", "Moon", "Globe", "MapPin", "ArrowRight", "BookOpen", "FileText", "Folder", "Calendar", "Users", "Image", "Camera", "Music", "Palette"]);
|
const common = new Set(["Star", "Heart", "Lightbulb", "Sparkles", "Zap", "Target", "Check", "Smile", "Coffee", "Leaf", "Sun", "Moon", "Globe", "MapPin", "ArrowRight", "BookOpen", "FileText", "Folder", "Calendar", "Users", "Image", "Camera", "Music", "Palette"]);
|
||||||
|
// Map以唯一name聚合元数据和SVG节点,Lucide加载器的__iconData提供路径,尚未在此写最终分片。
|
||||||
const entries = new Map();
|
const entries = new Map();
|
||||||
for (const [slug, load] of Object.entries(dynamicIconImports)) {
|
for (const [slug, load] of Object.entries(dynamicIconImports)) {
|
||||||
const name = pascal(slug);
|
const name = pascal(slug);
|
||||||
const { __iconData } = await load();
|
const { __iconData } = await load();
|
||||||
entries.set(name, { name, slug, source: "lucide", label: chinese[name] || slug.replaceAll("-", " "), node: __iconData.node });
|
entries.set(name, { name, slug, source: "lucide", label: chinese[name] || slug.replaceAll("-", " "), node: __iconData.node });
|
||||||
}
|
}
|
||||||
|
// Tabler只引入这24个outline图标,labels按同一索引配对;名称加Tabler前缀避免与Lucide冲突。
|
||||||
const tabler = ["alien", "ufo", "robot", "brain", "circuit-cell", "cube-3d-sphere", "hexagon-3d", "building-lighthouse", "cat", "dog", "fish", "leaf", "sun", "moon", "rocket", "bulb", "heart", "star", "photo", "movie", "music", "palette", "coffee", "mountain"];
|
const tabler = ["alien", "ufo", "robot", "brain", "circuit-cell", "cube-3d-sphere", "hexagon-3d", "building-lighthouse", "cat", "dog", "fish", "leaf", "sun", "moon", "rocket", "bulb", "heart", "star", "photo", "movie", "music", "palette", "coffee", "mountain"];
|
||||||
const tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"];
|
const tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"];
|
||||||
for (const [index, slug] of tabler.entries()) {
|
for (const [index, slug] of tabler.entries()) {
|
||||||
const svg = await readFile(resolve(tablerDir, "icons/outline", slug + ".svg"), "utf8");
|
const svg = await readFile(resolve(tablerDir, "icons/outline", slug + ".svg"), "utf8");
|
||||||
|
// 仅提取已知SVG图形标签与双引号属性,把连字符属性转为React驼峰;不是任意SVG/HTML的通用解析器。
|
||||||
const node = [...svg.matchAll(/<(path|circle|ellipse|rect|line|polyline|polygon)\s+([^>]+?)\/?\s*>/g)].map((match, index) => [match[1], Object.fromEntries([
|
const node = [...svg.matchAll(/<(path|circle|ellipse|rect|line|polyline|polygon)\s+([^>]+?)\/?\s*>/g)].map((match, index) => [match[1], Object.fromEntries([
|
||||||
["key", String(index)], ...[...match[2].matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()), value]),
|
["key", String(index)], ...[...match[2].matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()), value]),
|
||||||
])]);
|
])]);
|
||||||
@@ -42,19 +53,24 @@ for (const [index, slug] of tabler.entries()) {
|
|||||||
const name = "Tabler" + pascal(slug);
|
const name = "Tabler" + pascal(slug);
|
||||||
entries.set(name, { name, slug, source: "tabler", label: tablerLabels[index], node });
|
entries.set(name, { name, slug, source: "tabler", label: tablerLabels[index], node });
|
||||||
}
|
}
|
||||||
|
// 名称排序固定目录与后续分片插入顺序;相同输入有助于复现内容摘要。
|
||||||
const records = [...entries.values()].sort((a, b) => a.name.localeCompare(b.name, "en"));
|
const records = [...entries.values()].sort((a, b) => a.name.localeCompare(b.name, "en"));
|
||||||
const shards = new Map();
|
const shards = new Map();
|
||||||
|
/** 简单32位滚动hash模96,把Lucide名称稳定分桶;不是安全摘要,文件内容防缓存混淆另用SHA-256。 */
|
||||||
const bucket = (name) => { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return hash % 96; };
|
const bucket = (name) => { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return hash % 96; };
|
||||||
|
// 目录去掉SVG node,仅保留检索元数据;路径放入分片,Lucide分96桶,Tabler集中一个桶。
|
||||||
const catalog = records.map(({ node, ...entry }) => {
|
const catalog = records.map(({ node, ...entry }) => {
|
||||||
const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`;
|
const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`;
|
||||||
if (!shards.has(key)) shards.set(key, {});
|
if (!shards.has(key)) shards.set(key, {});
|
||||||
shards.get(key)[entry.name] = node;
|
shards.get(key)[entry.name] = node;
|
||||||
return { ...entry, categories: categories.filter(([, regex]) => regex.test(entry.slug)).map(([id]) => id), common: common.has(entry.name) || entry.source === "tabler", keywords: entry.slug.split("-").map((word) => keywords[word] || "").join(" ") };
|
return { ...entry, categories: categories.filter(([, regex]) => regex.test(entry.slug)).map(([id]) => id), common: common.has(entry.name) || entry.source === "tabler", keywords: entry.slug.split("-").map((word) => keywords[word] || "").join(" ") };
|
||||||
});
|
});
|
||||||
|
// 输出目录带两套版本,manifest把分片逻辑key映射到真实文件名;不在运行时猜hash路径。
|
||||||
const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
|
const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
|
||||||
await mkdir(assetDir, { recursive: true });
|
await mkdir(assetDir, { recursive: true });
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
const assets = {};
|
const assets = {};
|
||||||
|
// 每个分片用紧凑JSON计算SHA-256前12位作文件名,写文件额外加换行;totalBytes统计未加换行的内容字节。
|
||||||
for (const [key, nodes] of shards) {
|
for (const [key, nodes] of shards) {
|
||||||
const content = JSON.stringify(nodes);
|
const content = JSON.stringify(nodes);
|
||||||
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
|
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
|
||||||
@@ -63,8 +79,10 @@ for (const [key, nodes] of shards) {
|
|||||||
await writeFile(`${assetDir}/${filename}`, content + "\n");
|
await writeFile(`${assetDir}/${filename}`, content + "\n");
|
||||||
totalBytes += Buffer.byteLength(content);
|
totalBytes += Buffer.byteLength(content);
|
||||||
}
|
}
|
||||||
|
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
|
||||||
await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n");
|
await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n");
|
||||||
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
|
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
|
||||||
|
// 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。
|
||||||
await writeFile(`${assetDir}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/LICENSE"));
|
await writeFile(`${assetDir}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/LICENSE"));
|
||||||
await writeFile(`${assetDir}/TABLER-LICENSE.txt`, await readFile(resolve(tablerDir, "LICENSE")));
|
await writeFile(`${assetDir}/TABLER-LICENSE.txt`, await readFile(resolve(tablerDir, "LICENSE")));
|
||||||
console.log(`${records.length} icons (${records.filter((item) => item.source === "tabler").length} Tabler), ${shards.size} data shards, ${totalBytes} uncompressed bytes`);
|
console.log(`${records.length} icons (${records.filter((item) => item.source === "tabler").length} Tabler), ${shards.size} data shards, ${totalBytes} uncompressed bytes`);
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 应用编译期的ReactFlow可见性扩展。为视口外但仍有焦点/手势/浮层的显式保留对象扩展原生可见集合。
|
||||||
|
* 只转换匹配到的编译输入字符串,不修改共享node_modules文件,也不进入独立headless SDK的运行逻辑。
|
||||||
|
*/
|
||||||
// App-only extension for the pinned @xyflow/react 12.12.1 ESM renderer. This
|
// App-only extension for the pinned @xyflow/react 12.12.1 ESM renderer. This
|
||||||
// transforms compiler input; shared node_modules and the headless SDK are untouched.
|
// transforms compiler input; shared node_modules and the headless SDK are untouched.
|
||||||
|
// 读取安装包版本,固定12.12.1;库升级必须人工审查目标实现,不能凭相同函数名继续替换。
|
||||||
const version = require('@xyflow/react/package.json').version;
|
const version = require('@xyflow/react/package.json').version;
|
||||||
|
/** webpack/Turbopack loader接收源码并返回改写后的源码;任何版本或锚点不符立即失败,防止悄悄失去保留能力。 */
|
||||||
module.exports = function retainVisibility(source) {
|
module.exports = function retainVisibility(source) {
|
||||||
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react');
|
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react');
|
||||||
|
// 四个精确源码锚点覆盖节点查询、边可见性及选择订阅;依赖上游编译格式,不是通用AST补丁。
|
||||||
const nodes = 'getNodesInside(s.nodeLookup, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map((node) => node.id)';
|
const nodes = 'getNodesInside(s.nodeLookup, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map((node) => node.id)';
|
||||||
const edges = 'isEdgeVisible({\n sourceNode,';
|
const edges = 'isEdgeVisible({\n sourceNode,';
|
||||||
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
|
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
|
||||||
const close = 'transform: s.transform,\n })) {';
|
const close = 'transform: s.transform,\n })) {';
|
||||||
|
// 每个锚点只能出现一次,并拒绝重复处理已含标记的输入;避免替换错位置或把运算括号重复扩展。
|
||||||
if (source.includes('__endoleapRetain') || source.split(nodes).length !== 2 || source.split(edges).length !== 2 || source.split(close).length !== 2 || source.split(selection).length !== 2) {
|
if (source.includes('__endoleapRetain') || source.split(nodes).length !== 2 || source.split(edges).length !== 2 || source.split(close).length !== 2 || source.split(selection).length !== 2) {
|
||||||
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
|
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
|
||||||
}
|
}
|
||||||
|
// 节点追加未hidden且显式保留的ID并Set去重;边以保留标记或原可见判断二选一,维持原生索引。
|
||||||
|
// 最后一处改为空数组不订阅完整选择集合,减少无选择监听时的不必要全图selector工作。
|
||||||
return source
|
return source
|
||||||
.replace(nodes, `Array.from(new Set([
|
.replace(nodes, `Array.from(new Set([
|
||||||
...${nodes},
|
...${nodes},
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。
|
||||||
|
* 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。
|
||||||
|
* 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。
|
||||||
|
* 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。
|
||||||
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
import { useImageUploads } from "./use-image-uploads";
|
import { useImageUploads } from "./use-image-uploads";
|
||||||
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
|
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
|
||||||
@@ -41,15 +47,21 @@ import type { NodeTransform } from "@/lib/canvas-geometry";
|
|||||||
import { MaterialNode, NodeEditing } from "./material-node";
|
import { MaterialNode, NodeEditing } from "./material-node";
|
||||||
import styles from "./canvas.module.css";
|
import styles from "./canvas.module.css";
|
||||||
|
|
||||||
|
/** 旧边统一使用可编辑贝塞尔组件,节点按旧type名称分派;类型映射本身不持有元素实例数据。 */
|
||||||
const edgeTypes = { default: CanvasEdge };
|
const edgeTypes = { default: CanvasEdge };
|
||||||
|
/** 图标面板延迟在客户端加载,避免首屏加载目录面板及其交互代码;加载中提供可访问状态。 */
|
||||||
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
|
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
|
||||||
|
/** ReactFlow type到节点展示组件的固定表,须与makeCanvasNode/投影生成的type保持一致。 */
|
||||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
|
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
|
||||||
|
|
||||||
|
/** 每个挂载对应一个board身份,初始快照/草稿只在useState初始化时采用;切画布应由上层key重挂载。 */
|
||||||
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||||
const flow = useReactFlow<CanvasNode>();
|
const flow = useReactFlow<CanvasNode>();
|
||||||
const { zoom } = useViewport();
|
const { zoom } = useViewport();
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const storageKey = canvasStorageKey(board.workspace_id, board.id);
|
const storageKey = canvasStorageKey(board.workspace_id, board.id);
|
||||||
|
// 服务端快照优先,另有不同本地内容则等待恢复选择;无快照可直接用草稿。
|
||||||
|
// 视口独立读本地键并限制范围,损坏视口不阻断文档;草稿解码失败回退快照/空文档并提示。
|
||||||
const [initial] = useState(() => {
|
const [initial] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const local = readLocalRecovery(localStorage, storageKey, snapshot);
|
const local = readLocalRecovery(localStorage, storageKey, snapshot);
|
||||||
@@ -62,6 +74,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
|
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 工具与变换模式是界面状态,不放入文档;draggedIds仅驱动成员预览,membershipNotice短暂展示结果。
|
||||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||||
const [error, setError] = useState(initial.error);
|
const [error, setError] = useState(initial.error);
|
||||||
const feedback = useContext(FeedbackContext);
|
const feedback = useContext(FeedbackContext);
|
||||||
@@ -72,26 +85,35 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
const [rotating, setRotating] = useState<string | null>(null);
|
const [rotating, setRotating] = useState<string | null>(null);
|
||||||
const [draggedIds, setDraggedIds] = useState<string[]>([]);
|
const [draggedIds, setDraggedIds] = useState<string[]>([]);
|
||||||
const [membershipNotice, setMembershipNotice] = useState("");
|
const [membershipNotice, setMembershipNotice] = useState("");
|
||||||
|
// 防节点拖动与多选拖动回调重复进入;起点按显示ID保存,连线屏幕起点用于区分点击与拉出新分支。
|
||||||
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
|
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
|
||||||
const dragOrigins = useRef(new Map<string, XYPosition>());
|
const dragOrigins = useRef(new Map<string, XYPosition>());
|
||||||
const draggingFrame = useRef(false);
|
const draggingFrame = useRef(false);
|
||||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||||
|
// 图片分支先保留源节点/方向/位置,等文件选择返回;cancel事件要清它,不能沿用上次创建意图。
|
||||||
const pendingImage = useRef<BranchRequest | null>(null);
|
const pendingImage = useRef<BranchRequest | null>(null);
|
||||||
|
/** 稳定关闭回调供Portal菜单与画布事件共用,避免菜单因每次渲染重新安装监听。 */
|
||||||
const closeBranch = useCallback(() => setBranchRequest(null), []);
|
const closeBranch = useCallback(() => setBranchRequest(null), []);
|
||||||
const imageKind = useRef<"image" | "plain-image">("image");
|
const imageKind = useRef<"image" | "plain-image">("image");
|
||||||
const imageInput = useRef<HTMLInputElement>(null);
|
const imageInput = useRef<HTMLInputElement>(null);
|
||||||
const projectInput = useRef<HTMLInputElement>(null);
|
const projectInput = useRef<HTMLInputElement>(null);
|
||||||
const [recovery, setRecovery] = useState(initial.recovery);
|
const [recovery, setRecovery] = useState(initial.recovery);
|
||||||
|
// 根恢复待决时暂停保存;tree负责真实owner文档,UI显示节点来自其树投影。子图恢复按Map首项逐个处理。
|
||||||
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
|
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
|
||||||
const childRecovery = [...tree.recoveries][0];
|
const childRecovery = [...tree.recoveries][0];
|
||||||
|
/** 下载单份内存文档JSON备份,稍后释放Blob URL;不修改草稿选择,也不保证包含所有子图。 */
|
||||||
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
|
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
|
||||||
const sync = tree;
|
const sync = tree;
|
||||||
|
// snapshot存在才执行真实AI;state.present组合根元数据与整树显示节点/边,不能把它当单board持久快照。
|
||||||
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
|
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
|
||||||
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
|
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
|
||||||
const treeRef = useRef(tree); treeRef.current = tree;
|
const treeRef = useRef(tree); treeRef.current = tree;
|
||||||
|
// 上传完成回调通过treeRef定位当前归属,并更新当前文档与历史;job.owner可能已不再是最新owner。
|
||||||
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
|
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
|
||||||
const imageUploading = demoImageUploading;
|
const imageUploading = demoImageUploading;
|
||||||
|
// 文件导入等待期间的迁移检查标记;这不是通用导入队列,也不提供并发文件事务锁。
|
||||||
const ioActive=useRef(false);
|
const ioActive=useRef(false);
|
||||||
|
// 上传任务与文档都可能变化:按pendingUpload重新找owner,补资源或纯图片默认尺寸,避免移动/撤销后遗漏占位。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = treeRef.current;
|
const current = treeRef.current;
|
||||||
for (const job of uploads.jobs) {
|
for (const job of uploads.jobs) {
|
||||||
@@ -102,38 +124,48 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
|
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
|
||||||
}
|
}
|
||||||
}, [uploads.jobs,tree.documents]);
|
}, [uploads.jobs,tree.documents]);
|
||||||
|
/** undo/redo走异步树历史接口并显示失败,其余动作走根document reducer;不把服务端事务撤销当普通reducer动作。 */
|
||||||
function dispatch(action: CanvasAction) {
|
function dispatch(action: CanvasAction) {
|
||||||
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
|
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
|
||||||
treeRef.current.dispatch(action);
|
treeRef.current.dispatch(action);
|
||||||
}
|
}
|
||||||
|
// 浏览器文件选择取消不触发普通onChange,单独监听cancel清理待建图片分支;卸载拆监听。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const input = imageInput.current;
|
const input = imageInput.current;
|
||||||
|
/** 取消本次选择意图,不删除已经存在的图片节点或中止已启动上传。 */
|
||||||
const cancel = () => { pendingImage.current = null; };
|
const cancel = () => { pendingImage.current = null; };
|
||||||
input?.addEventListener("cancel", cancel);
|
input?.addEventListener("cancel", cancel);
|
||||||
return () => input?.removeEventListener("cancel", cancel);
|
return () => input?.removeEventListener("cancel", cancel);
|
||||||
}, []);
|
}, []);
|
||||||
const { nodes: displayNodes, edges } = state.present;
|
const { nodes: displayNodes, edges } = state.present;
|
||||||
|
// 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。
|
||||||
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
|
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
|
||||||
|
/** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示ID。 */
|
||||||
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
|
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
|
||||||
|
// 预览URL按显示ID提供;正式图片onLoad再映射回job,上传完成的任务才回收本地URL。
|
||||||
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
|
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
|
||||||
|
|
||||||
|
// 从场景节点派生分区名称、已有frameId计数与本次拖入/拖出候选,只作显示,不在此改变owner。
|
||||||
const frameFeedback = useMemo(() => {
|
const frameFeedback = useMemo(() => {
|
||||||
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
|
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1);
|
for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1);
|
||||||
return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
|
return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
|
||||||
}, [nodes, draggedIds]);
|
}, [nodes, draggedIds]);
|
||||||
|
// 成员提示四秒后清理;重新提示重置计时器,卸载取消。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!membershipNotice) return;
|
if (!membershipNotice) return;
|
||||||
const timer = setTimeout(() => setMembershipNotice(""), 4000);
|
const timer = setTimeout(() => setMembershipNotice(""), 4000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [membershipNotice]);
|
}, [membershipNotice]);
|
||||||
|
// 全局反馈可用时把本地错误转成稳定ID通知并清本地error;无Provider时保留底部内联提示。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!feedback || !error) return;
|
if (!feedback || !error) return;
|
||||||
const id = `canvas:${board.id}:error`;
|
const id = `canvas:${board.id}:error`;
|
||||||
notify({ id, level: "error", title: error });
|
notify({ id, level: "error", title: error });
|
||||||
setError("");
|
setError("");
|
||||||
}, [feedback, error, board.id]);
|
}, [feedback, error, board.id]);
|
||||||
|
// 成员通知使用board级稳定ID,内容变化替换旧通知,清理时显式dismiss。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!feedback || !membershipNotice) return;
|
if (!feedback || !membershipNotice) return;
|
||||||
const id = `canvas:${board.id}:membership`;
|
const id = `canvas:${board.id}:membership`;
|
||||||
@@ -141,29 +173,40 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return () => dismissNotification(id);
|
return () => dismissNotification(id);
|
||||||
}, [feedback, membershipNotice, board.id]);
|
}, [feedback, membershipNotice, board.id]);
|
||||||
const duration = reduceMotion ? 0 : 180;
|
const duration = reduceMotion ? 0 : 180;
|
||||||
|
/** 视口派发给根并独立写本地键;存储失败不打断浏览,内容writer自行判断是否需要服务端保存。 */
|
||||||
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
|
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
|
||||||
treeRef.current.dispatch({ type: "viewport", viewport });
|
treeRef.current.dispatch({ type: "viewport", viewport });
|
||||||
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
|
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
|
||||||
};
|
};
|
||||||
|
/** 工具栏视口动画完成后保存实际视口;错误转界面提示,不提前记录尚未完成的目标值。 */
|
||||||
const changeView = (operation: () => Promise<boolean>) => {
|
const changeView = (operation: () => Promise<boolean>) => {
|
||||||
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
|
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// latest保留当前显示几何,拖动结束基于最新节点计算成员反馈,避免只使用开始时的闭包。
|
||||||
const latest = useRef(state.present);
|
const latest = useRef(state.present);
|
||||||
latest.current = state.present;
|
latest.current = state.present;
|
||||||
|
|
||||||
|
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
|
||||||
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
||||||
|
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
|
||||||
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
||||||
|
/** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */
|
||||||
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
||||||
|
/** 打开分支类型菜单并退出变换模式,point用于屏幕定位。 */
|
||||||
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
|
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
|
||||||
|
/** 将场景位置减owner原点再除scale,宽高也除scale;旋转等其余字段直接交旧transform动作。 */
|
||||||
const transform = useCallback((id: string, transform: NodeTransform) => {
|
const transform = useCallback((id: string, transform: NodeTransform) => {
|
||||||
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
|
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
|
||||||
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
|
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
|
||||||
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
|
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
|
||||||
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
|
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
|
||||||
}, []);
|
}, []);
|
||||||
|
/** 样式适配只构造旧style动作,具体字段合并由reducer处理。 */
|
||||||
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
|
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
|
||||||
|
/** 节点名称通过owner映射更新,避免将显示复合ID当持久ID。 */
|
||||||
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
|
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
|
||||||
|
/** 分区属性以replace提交;折叠切换从已保存expanded/collapsedSize或默认值恢复尺寸,并写node与style。 */
|
||||||
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
|
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
|
||||||
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
|
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
|
||||||
const document = current.documents.get(ref.boardId)!;
|
const document = current.documents.get(ref.boardId)!;
|
||||||
@@ -174,7 +217,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
|
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
|
||||||
}) } }, ref.boardId);
|
}) } }, ref.boardId);
|
||||||
}, []);
|
}, []);
|
||||||
|
// 注入节点动作及本地旋转/拉伸模式,两模式互斥;旧版接口没有新版手势token租约。
|
||||||
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
|
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
|
||||||
|
/** 在源owner文档创建节点与连线,at从场景坐标转局部;发布后尝试把视口移向新节点,异常留作提示。 */
|
||||||
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
|
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
|
||||||
try {
|
try {
|
||||||
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
||||||
@@ -184,11 +229,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
setBranchRequest(null);
|
setBranchRequest(null);
|
||||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||||
}
|
}
|
||||||
|
/** 图片分支需要文件选择才能创建,先保存request并关菜单;文本/脚本立即创建。 */
|
||||||
function chooseBranch(kind: "text" | "image" | "script") {
|
function chooseBranch(kind: "text" | "image" | "script") {
|
||||||
if (!branchRequest) return;
|
if (!branchRequest) return;
|
||||||
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
|
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
|
||||||
else createBranch(branchRequest, kind);
|
else createBranch(branchRequest, kind);
|
||||||
}
|
}
|
||||||
|
/** 无有效目标的连接拖动若移动至少5px并落到空白pane,且源仍选中,则在落点打开分支菜单。 */
|
||||||
const connectionEnd: OnConnectEnd = (event, connection) => {
|
const connectionEnd: OnConnectEnd = (event, connection) => {
|
||||||
const start = connectionStart.current;
|
const start = connectionStart.current;
|
||||||
connectionStart.current = null;
|
connectionStart.current = null;
|
||||||
@@ -201,11 +248,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
|
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 按窗口中心转换为场景点并加六档偏移,减少连续添加完全重叠;不是按编辑器容器中心计算。 */
|
||||||
const center = () => {
|
const center = () => {
|
||||||
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
||||||
const offset = (nodes.length % 6) * 18;
|
const offset = (nodes.length % 6) * 18;
|
||||||
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
|
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
|
||||||
};
|
};
|
||||||
|
/** 根节点上限500;真实分区走subgraphs命令创建子画布,其它节点本地加入,演示frame也走本地模型。 */
|
||||||
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
|
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
|
||||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
||||||
const node = makeCanvasNode(kind, center(), src);
|
const node = makeCanvasNode(kind, center(), src);
|
||||||
@@ -218,6 +267,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
|
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
|
||||||
setTool("select");
|
setTool("select");
|
||||||
}
|
}
|
||||||
|
/** 一次replace插入三张预填卡片和两条连线,预留三个节点容量并切回选择工具;不触发AI生成。 */
|
||||||
function addTemplate() {
|
function addTemplate() {
|
||||||
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
||||||
const position = center();
|
const position = center();
|
||||||
@@ -233,6 +283,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
] } });
|
] } });
|
||||||
setTool("select");
|
setTool("select");
|
||||||
}
|
}
|
||||||
|
/** idle才建立checkpoint与拖动起点;有frame参与则不显示普通拖入预览,防止移动分区误收纳覆盖内容。 */
|
||||||
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||||
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
|
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
|
||||||
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
|
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
|
||||||
@@ -242,6 +293,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
|
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
|
||||||
setMembershipNotice("");
|
setMembershipNotice("");
|
||||||
}
|
}
|
||||||
|
/** 只结束active拖动,先清拖动/保存暂停标记,再按真实位移计算归属;真实子图与旧frameId归属分路处理。
|
||||||
|
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
|
||||||
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||||
if (dragSession.current !== "active") return;
|
if (dragSession.current !== "active") return;
|
||||||
const origins = new Map(dragOrigins.current);
|
const origins = new Map(dragOrigins.current);
|
||||||
@@ -252,19 +305,23 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
const positions = new Map(items.map((item) => [item.id, item.position]));
|
const positions = new Map(items.map((item) => [item.id, item.position]));
|
||||||
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
|
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
|
||||||
|
// 先生成“加入/移出”展示文案;实际owner由下面的真实子图路径重新判定,成功后才显示结果通知。
|
||||||
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
|
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
|
||||||
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
|
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
|
||||||
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
|
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
|
||||||
if (snapshot && tree.rootDocument.schema_version === 2) {
|
if (snapshot && tree.rootDocument.schema_version === 2) {
|
||||||
try {
|
try {
|
||||||
|
// 按source/target分组,同组节点一起迁移;候选分区按面积升序,优先最小包围分区。
|
||||||
const byOwner = new Map<string, { ids: string[]; target: string }>();
|
const byOwner = new Map<string, { ids: string[]; target: string }>();
|
||||||
const parents = subgraphParents(tree.current.current);
|
const parents = subgraphParents(tree.current.current);
|
||||||
for (const item of items.filter((item) => ids.includes(item.id))) {
|
for (const item of items.filter((item) => ids.includes(item.id))) {
|
||||||
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
|
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
|
||||||
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
|
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
|
||||||
|
/** 沿子图父链防环检查,不能把分区入口移动进自己的后代,形成结构循环。 */
|
||||||
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
|
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
|
||||||
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
|
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
|
||||||
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
|
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
|
||||||
|
// 未上传且满足条件的占位图片先走本地迁移;其它节点需要服务端成员事务。
|
||||||
if (target !== ref.boardId && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
|
if (target !== ref.boardId && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
|
||||||
tree.movePendingImage(ref.boardId,target,ref.id);continue;
|
tree.movePendingImage(ref.boardId,target,ref.id);continue;
|
||||||
}
|
}
|
||||||
@@ -272,6 +329,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
|
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
|
||||||
await Promise.all([...byOwner].map(async ([key, group]) => {
|
await Promise.all([...byOwner].map(async ([key, group]) => {
|
||||||
let source = key.split("/")[0];
|
let source = key.split("/")[0];
|
||||||
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
||||||
@@ -287,10 +345,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 先把入口端口解析成实际内部节点;同owner本地加边,跨owner优先服务端connections事务,缺能力时尝试暴露端口适配。 */
|
||||||
async function connect(connection: Connection) {
|
async function connect(connection: Connection) {
|
||||||
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
|
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
|
||||||
if (!source || !target) return;
|
if (!source || !target) return;
|
||||||
try {
|
try {
|
||||||
|
/** 按深度计数0—8解析子图入口,每层必须找到明确port映射;仍未到实际节点或缺端口则拒绝。 */
|
||||||
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
|
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
|
||||||
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
|
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
|
||||||
for (let depth = 0; depth <= 8; depth++) {
|
for (let depth = 0; depth <= 8; depth++) {
|
||||||
@@ -303,6 +363,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
throw new Error("subgraph_cycle");
|
throw new Error("subgraph_cycle");
|
||||||
};
|
};
|
||||||
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
|
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
|
||||||
|
// 同一canvas快照可直接加边;跨canvas需要共同上层/边界端口关系,不能把显示端点直接写进任一子快照。
|
||||||
if (realSource.boardId === realTarget.boardId) {
|
if (realSource.boardId === realTarget.boardId) {
|
||||||
const document = tree.current.current.get(realSource.boardId)!;
|
const document = tree.current.current.get(realSource.boardId)!;
|
||||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
|
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
|
||||||
@@ -316,11 +377,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 兼容路径由exposedConnection提供可寻址端点与owner,失败提示跨画布连接暂不可用。
|
||||||
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
|
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
|
||||||
const document = tree.current.current.get(exposed.boardId)!;
|
const document = tree.current.current.get(exposed.boardId)!;
|
||||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
|
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
|
||||||
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
|
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
|
||||||
}
|
}
|
||||||
|
/** 校验MIME与大小并清文件input;迁移期间拒绝。真实画布通过上传占位,演示以Data URL直接加入。 */
|
||||||
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
@@ -331,6 +394,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
}
|
}
|
||||||
const request = pendingImage.current; pendingImage.current = null;
|
const request = pendingImage.current; pendingImage.current = null;
|
||||||
const kind = imageKind.current;
|
const kind = imageKind.current;
|
||||||
|
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
try { const job = uploads.start(file, (src) => {
|
try { const job = uploads.start(file, (src) => {
|
||||||
const current = treeRef.current;
|
const current = treeRef.current;
|
||||||
@@ -343,12 +407,14 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
|
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
|
||||||
return {owner:board.id,node};
|
return {owner:board.id,node};
|
||||||
});
|
});
|
||||||
|
// pendingUpload以node.id标记,后续资源完成/跨区移动/历史恢复都按这个身份找到占位。
|
||||||
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
|
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
|
||||||
const node={...job.node,pendingUpload:job.node.id};
|
const node={...job.node,pendingUpload:job.node.id};
|
||||||
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
|
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
|
||||||
setTool("select"); } catch(error) {setError(errorText(error));}
|
setTool("select"); } catch(error) {setError(errorText(error));}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
|
||||||
setImageUploading(true);
|
setImageUploading(true);
|
||||||
try {
|
try {
|
||||||
const src = await new Promise<string>((resolve, reject) => {
|
const src = await new Promise<string>((resolve, reject) => {
|
||||||
@@ -370,6 +436,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
|
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
|
||||||
finally { setImageUploading(false); }
|
finally { setImageUploading(false); }
|
||||||
}
|
}
|
||||||
|
/** 旧版导入最多5MB并全量读取解码,替换当前根快照;任一方含真实子图时拒绝,避免破坏跨board关系。
|
||||||
|
* ioActive用于阻止迁移交接;这条路径不是新版渐进导入,也不会自动重建bundle子图。 */
|
||||||
async function importProject(event: ChangeEvent<HTMLInputElement>) {
|
async function importProject(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
@@ -387,6 +455,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
|
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
|
||||||
finally{ioActive.current=false;}
|
finally{ioActive.current=false;}
|
||||||
}
|
}
|
||||||
|
/** 有上传占位则先等待用户完成;树导出完整可达文档,多board写bundle,单board写当前视口。
|
||||||
|
* 下载名清理文件名非法字符,Blob URL延迟释放;不额外打包File/Blob,已有演示Data URL仍随src导出。 */
|
||||||
async function exportProject() {
|
async function exportProject() {
|
||||||
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
||||||
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
||||||
@@ -399,7 +469,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */
|
||||||
const keydown = (event: KeyboardEvent) => {
|
const keydown = (event: KeyboardEvent) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
@@ -417,6 +489,11 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return () => window.removeEventListener("keydown", keydown);
|
return () => window.removeEventListener("keydown", keydown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
|
||||||
|
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
|
||||||
|
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
|
||||||
|
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
|
||||||
|
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||||
<ImagePreviews.Provider value={imagePreviews}>
|
<ImagePreviews.Provider value={imagePreviews}>
|
||||||
@@ -427,6 +504,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
|
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
|
||||||
onBeforeDelete={async ({ nodes: removing }) => {
|
onBeforeDelete={async ({ nodes: removing }) => {
|
||||||
if (tree.busy || tree.transactionBusy) return false;
|
if (tree.busy || tree.transactionBusy) return false;
|
||||||
|
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
|
||||||
const linked = removing.filter((node) => node.data.subgraph);
|
const linked = removing.filter((node) => node.data.subgraph);
|
||||||
const parents = subgraphParents(tree.current.current);
|
const parents = subgraphParents(tree.current.current);
|
||||||
const referenced = removing.filter((node) => {
|
const referenced = removing.filter((node) => {
|
||||||
@@ -436,6 +514,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
});
|
});
|
||||||
if (!linked.length && !referenced.length) return true;
|
if (!linked.length && !referenced.length) return true;
|
||||||
try {
|
try {
|
||||||
|
// 分区逐个事务解散,参与者包含父、子、孙及需要调整引用的祖先;并非一批选择全体原子操作。
|
||||||
for (const node of linked) {
|
for (const node of linked) {
|
||||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||||
await tree.ensure(node.data.subgraph!.boardId);
|
await tree.ensure(node.data.subgraph!.boardId);
|
||||||
@@ -445,6 +524,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
|
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
|
||||||
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
|
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
|
||||||
}
|
}
|
||||||
|
// 解散后owner可能变化,按原ID重新查唯一归属;找不到唯一目标就跳过,不误删同ID的其它节点。
|
||||||
for (const node of removing.filter((node) => !node.data.subgraph)) {
|
for (const node of removing.filter((node) => !node.data.subgraph)) {
|
||||||
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
|
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
|
||||||
let owner = ref.boardId;
|
let owner = ref.boardId;
|
||||||
@@ -461,6 +541,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
}
|
}
|
||||||
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
|
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
|
||||||
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
|
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
|
||||||
|
// 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
onNodesChange={tree.nodesChanged}
|
onNodesChange={tree.nodesChanged}
|
||||||
@@ -486,6 +567,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
|
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
|
||||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
||||||
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
|
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
|
||||||
|
// AIPanel收到owner原始节点,显示ID仅用于NodeToolbar定位;save仍经tree.nodeAction回写owner。
|
||||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||||
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
|
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
|
||||||
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
|
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
|
||||||
@@ -558,6 +640,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
|
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
|
||||||
</div>}
|
</div>}
|
||||||
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
|
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
|
||||||
|
// 成员任务提示跟随当前source/target中的场景节点,队首同步、后续等待,失败通过retryMembers继续。
|
||||||
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
|
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
|
||||||
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
|
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
|
||||||
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
|
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
|
||||||
@@ -577,6 +660,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为单个旧编辑器建立独立ReactFlow store;board/snapshot身份切换的重挂载由路由调用方负责。 */
|
||||||
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||||
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
|
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 新版主画布的React/ReactFlow装配。engine拥有文档、历史和运行事实,sessions拥有每视图选择/工具/手势;组件只保存显示缓存与面板状态。
|
||||||
|
* 文档提交先推进场景索引,再按事件类型立即或逐帧发布;几何预览通过session进入投影,提交才产生文档修改。
|
||||||
|
* 画布适配层负责DOM测量、命中、裁剪、焦点和资源显示,core不读取DOM;渲染组件通过动作上下文调用editor/runtime。
|
||||||
|
* 外层只借用项目engine:挂载attach视图,卸载detach,不关闭工作区持有的项目或持久任务。
|
||||||
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
|
||||||
@@ -40,9 +46,13 @@ import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
|
|||||||
import { ImagePreviews } from "./canvas-image";
|
import { ImagePreviews } from "./canvas-image";
|
||||||
import styles from "./canvas.module.css";
|
import styles from "./canvas.module.css";
|
||||||
|
|
||||||
|
/** 把适配层样式等可序列化值规范化成JSON Data,去掉undefined等JSON不保留的属性;不处理循环或任意非JSON对象。 */
|
||||||
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
||||||
|
/** 已attach视图的内容组件;需要外层ReactFlowProvider与CanvasProvider。root/session是发布快照,current供回调读取最新投影。 */
|
||||||
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
|
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
|
||||||
|
// 场景索引按engine+view复用,提交订阅先advance变更,再通知显示,避免每次渲染重建整图索引。
|
||||||
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
|
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
|
||||||
|
/** 用户字段修改/替换删除及undo/redo立即发布,保证受控输入及时回显;批量创建和后台修改允许合并到下一帧。 */
|
||||||
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
|
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
|
||||||
let before=engine.getSnapshot().document;
|
let before=engine.getSnapshot().document;
|
||||||
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
|
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
|
||||||
@@ -52,16 +62,20 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
notify(immediate);
|
notify(immediate);
|
||||||
});
|
});
|
||||||
}, [engine,sceneCache]);
|
}, [engine,sceneCache]);
|
||||||
|
// rootFrames只调度呈现通知,不延迟editor真实提交;sessionFrames独立合并手势/选择等视图变化。
|
||||||
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
|
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
|
||||||
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
|
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
|
||||||
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
|
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
|
||||||
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
|
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
|
||||||
|
// 视图被detach后保留最近view供本轮Hook计算,最终返回“画布已关闭”;不以lastView恢复已关闭会话。
|
||||||
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
|
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
|
||||||
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
|
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
|
||||||
|
|
||||||
if (liveView) lastView.current = liveView;
|
if (liveView) lastView.current = liveView;
|
||||||
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
|
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
|
||||||
|
// 完整加载后允许普通创建;加载中要求loading版本支持且当前canvas覆盖完整,最终命令层仍再校验准入。
|
||||||
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
||||||
|
// 加载预览首次适配只取当前canvas直接节点,避免展开后代把视口拉得过远;有交接视口时不再自动fit。
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
|
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
|
||||||
// Expanded descendants can extend far beyond their partition entry. Initial
|
// Expanded descendants can extend far beyond their partition entry. Initial
|
||||||
@@ -70,7 +84,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
if(!nodes.length)return;
|
if(!nodes.length)return;
|
||||||
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
|
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
|
||||||
},[engine,flow,root.document,view.canvasId]);
|
},[engine,flow,root.document,view.canvasId]);
|
||||||
|
// 复制准备任务来自runtime状态;copying防本地重复点击,两者共同禁用复制入口。
|
||||||
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
|
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
|
||||||
|
// 焦点与epoch用于保留活动输入和排除迟到blur;切engine/view复位,移除renderer未触发blur时另检查isConnected。
|
||||||
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
|
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
|
||||||
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
||||||
// Removing an active renderer need not dispatch a native blur. Retire only
|
// Removing an active renderer need not dispatch a native blur. Retire only
|
||||||
@@ -78,19 +94,26 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
|
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
|
||||||
const [copying, setCopying] = useState(false);
|
const [copying, setCopying] = useState(false);
|
||||||
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
|
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
|
||||||
|
// 选择转成id/incarnation后查询深复制可用性;渲染时的可用性仅用于UI,点击时再次捕获实际选择交runtime。
|
||||||
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
|
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
|
||||||
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
// resource显示状态带engine/view身份,旧显示器迟到回调不能为当前画布提供错误图片;sources不是文档资源表。
|
||||||
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
||||||
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
|
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
|
||||||
|
// 拖动保存起始场景坐标、指针ID与会话token,positions只含顶层选中节点,防分区与成员重复位移。
|
||||||
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
|
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
|
||||||
|
// 复用投影对象与测量结果,避免ReactFlow把纯元数据变化误当节点卸载,影响输入及连接点。
|
||||||
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
|
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
|
||||||
projectionCache.current ??= createProjectionCache();
|
projectionCache.current ??= createProjectionCache();
|
||||||
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
||||||
|
// groups按元素生命周期保存一次文本编辑的history分组ID,元素重建后清旧分组,不能跨incarnation合并编辑。
|
||||||
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
|
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
|
||||||
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
|
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
|
||||||
|
// 加载期使用受限renderer catalog,完整后回到正常定义;sceneCache把文档、会话预览与查询组成显示场景。
|
||||||
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
|
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
|
||||||
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
|
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
|
||||||
|
// 仅在尺寸一致且投影缓存确认身份/变换允许时复用measured;离开场景的节点清测量条目。
|
||||||
const projection = useMemo(() => {
|
const projection = useMemo(() => {
|
||||||
const result = scene;
|
const result = scene;
|
||||||
// Preserve renderer measurements across metadata/preview updates. Dropping them
|
// Preserve renderer measurements across metadata/preview updates. Dropping them
|
||||||
@@ -100,36 +123,50 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
|
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
|
||||||
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
|
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
|
||||||
}, [engine, root.document, scene,view.canvasId]);
|
}, [engine, root.document, scene,view.canvasId]);
|
||||||
|
// 每个view独立OverlayIndex,Portal控件通过索引贡献边界与焦点;不会把浮层几何存入项目文档。
|
||||||
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
|
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
|
||||||
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
|
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
|
||||||
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
|
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
|
||||||
const surfaceBounds=surface.current?.getBoundingClientRect();
|
const surfaceBounds=surface.current?.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 绘制范围索引用于可见性筛选;sceneViewport把ReactFlow平移/缩放反算为场景矩形。
|
||||||
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
|
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
|
||||||
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
|
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
|
||||||
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
|
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
|
||||||
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
|
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
|
||||||
|
// 过滤元素生命周期已变的浮层;缺surface矩形时只按身份保守保留,不凭缺失测量断言不可见。
|
||||||
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
|
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
|
||||||
|
// 未知绘制范围必须保留,加上矩形查询命中;不能把unknown当成屏幕外。
|
||||||
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
|
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
|
||||||
const nativeNodes=useStore(state=>state.nodes);
|
const nativeNodes=useStore(state=>state.nodes);
|
||||||
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
|
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
|
||||||
|
// 连线范围依赖真实Handle测量:新版/未知连线使用measuredConnector,兼容节点使用旧几何适配。
|
||||||
|
// 订阅nativeNodes让测量更新后重算连线路径范围,不把DOM测量要求下放core。
|
||||||
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
|
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
|
||||||
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
|
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
|
||||||
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
|
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
|
||||||
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
|
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
|
||||||
|
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
|
||||||
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||||
|
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
|
||||||
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
||||||
|
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
|
||||||
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
||||||
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
||||||
}, []);
|
}, []);
|
||||||
|
/** 发命令前读取当前incarnation,元素缺失即抛错;渲染侧更早的身份检查由useScopedNodeEditing等负责。 */
|
||||||
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
|
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
|
||||||
|
/** 统一元素data补丁入口,可携history groupId;schema、能力和事务合法性留editor验证。 */
|
||||||
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
|
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
|
||||||
|
/** 把ReactFlow选择变化合到session当前Set,成员完全相同则不重复发布;不把selected写入文档。 */
|
||||||
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
|
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
|
||||||
const current = engine.sessions.getSnapshot().views.get(viewId)!;
|
const current = engine.sessions.getSnapshot().views.get(viewId)!;
|
||||||
const ids = new Set(current.selection);
|
const ids = new Set(current.selection);
|
||||||
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
|
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
|
||||||
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
|
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
|
||||||
}
|
}
|
||||||
|
/** 空白点击补充core投影命中:12px容差、预览目标及实际Handle/layer适配;只选择confirmed命中。
|
||||||
|
* 命中计算后再次比较document/session快照身份,过期帧不能选中新incarnation;抓手模式点击清选择。 */
|
||||||
function selectAtPane(event: {clientX:number;clientY:number}) {
|
function selectAtPane(event: {clientX:number;clientY:number}) {
|
||||||
const captured=current.current;
|
const captured=current.current;
|
||||||
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
|
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
|
||||||
@@ -149,12 +186,16 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const hit=result.hits[0];
|
const hit=result.hits[0];
|
||||||
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
|
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
|
||||||
}
|
}
|
||||||
|
/** session.begin成功后捕获gesture token,以guardEditingLease约束后续preview/commit/cancel只作用于该交互。 */
|
||||||
function beginTransform(id: Id) {
|
function beginTransform(id: Id) {
|
||||||
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
|
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
|
||||||
const token = engine.sessions.getSnapshot().gesture!.token;
|
const token = engine.sessions.getSnapshot().gesture!.token;
|
||||||
|
/** 每次租约动作实时核对token,旧pointerup不能结束后来新建的手势。 */
|
||||||
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
|
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
|
||||||
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||||
}
|
}
|
||||||
|
/** 将显示空间位置/尺寸转成owner局部Geometry:尺寸除scale,以中心toLocal后再求左上角,旋转减去父旋转。
|
||||||
|
* 这里只更新session预览,不直接dispatch文档;后续commit统一形成history与同步修改。 */
|
||||||
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
|
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
|
||||||
const { root, projection } = current.current, element = root.document.elements.get(id);
|
const { root, projection } = current.current, element = root.document.elements.get(id);
|
||||||
if (element?.structure !== 'node') return;
|
if (element?.structure !== 'node') return;
|
||||||
@@ -165,16 +206,21 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
||||||
report(engine.sessions.update(viewId, target(id), patch));
|
report(engine.sessions.update(viewId, target(id), patch));
|
||||||
}
|
}
|
||||||
|
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
|
||||||
|
* 默认位置从首个data-headless-canvas的DOM矩形取屏幕中心再转场景坐标,at可显式覆盖。 */
|
||||||
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
|
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
|
||||||
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
|
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
|
||||||
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
|
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
|
||||||
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
|
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
|
||||||
|
// 各类型使用固定默认尺寸及初始incarnation/dataVersion;资源引用由已stage/adopt的ref注入,不在这里上传。
|
||||||
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
|
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
|
||||||
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
||||||
|
/** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */
|
||||||
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
||||||
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
/** 选择工具才可建边;拒绝unsupported端点,将owner设为两端所属canvas的共同上级,端口ID来自连接事件。 */
|
||||||
function connect(connection: Connection) {
|
function connect(connection: Connection) {
|
||||||
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
|
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
|
||||||
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
|
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
|
||||||
@@ -182,21 +228,30 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
|
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
|
||||||
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
||||||
}
|
}
|
||||||
|
// 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。
|
||||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||||
|
// 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!liveView) return;
|
if (!liveView) return;
|
||||||
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
|
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
|
||||||
resourceView.current=view;
|
resourceView.current=view;
|
||||||
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
|
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
|
||||||
}, [engine,viewId,Boolean(liveView)]);
|
}, [engine,viewId,Boolean(liveView)]);
|
||||||
|
// 资源目标集合变化才sync,映射失败在本面板报错,不擅自修改文档中的assetId。
|
||||||
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
|
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
|
||||||
|
// 再按engine/view及当前assetId过滤结果,节点切换资源时不显示上一个资源的迟到URL。
|
||||||
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
|
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
|
||||||
|
/** 一次事务删除当前选择中仍存在的元素;关联结构处理由editor命令实现,不循环修改投影数组。 */
|
||||||
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
||||||
|
// 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。
|
||||||
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
||||||
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
||||||
|
// 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/** 核对viewId后取消session并清本地拖动记录;window blur不要求指针事件。 */
|
||||||
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
|
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
|
||||||
// Bubble after the target/React handlers: an input owner can claim Escape first.
|
// Bubble after the target/React handlers: an input owner can claim Escape first.
|
||||||
|
/** 冒泡阶段让输入控件优先认领Escape,再按统一路由决定是否取消手势。 */
|
||||||
const escape = (event: KeyboardEvent) => {
|
const escape = (event: KeyboardEvent) => {
|
||||||
if(event.key!=='Escape')return;
|
if(event.key!=='Escape')return;
|
||||||
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
|
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
|
||||||
@@ -208,10 +263,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const modes = view.modes;
|
const modes = view.modes;
|
||||||
// Retain explicit owners in the native renderer, leaving the full graph in its
|
// Retain explicit owners in the native renderer, leaving the full graph in its
|
||||||
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
|
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
|
||||||
|
// 保留模式目标、可见绘制对象、浮层目标和本视图手势;完整图仍留ReactFlow查询表,不以伪造拖动避裁剪。
|
||||||
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
|
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
|
||||||
for(const entry of visibleOverlays)retained.add(entry.target.id);
|
for(const entry of visibleOverlays)retained.add(entry.target.id);
|
||||||
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
|
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
|
||||||
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
|
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
|
||||||
|
// 活动输入可来自节点、连线、Portal浮层或独立UI;无法证明其归属时保守关闭可见性裁剪。
|
||||||
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
|
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
|
||||||
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
|
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
|
||||||
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
|
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
|
||||||
@@ -221,10 +278,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
if(focusedId)retained.add(focusedId);
|
if(focusedId)retained.add(focusedId);
|
||||||
// Retained connections also keep their measured endpoint owners alive.
|
// Retained connections also keep their measured endpoint owners alive.
|
||||||
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
|
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
|
||||||
|
// 所有保留对象仍须存在于文档,且视口尺寸已知,才启用native onlyRenderVisibleElements。
|
||||||
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
|
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
|
||||||
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
|
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
|
||||||
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
|
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
|
||||||
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
|
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
|
||||||
|
// 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。
|
||||||
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
|
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
|
||||||
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
||||||
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
||||||
@@ -233,15 +292,19 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
// Actions use current.current / engine snapshots instead of captured document projections.
|
// Actions use current.current / engine snapshots instead of captured document projections.
|
||||||
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
|
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
|
||||||
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
|
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
|
||||||
|
// 新版资源URL寿命由resourceView管理,CanvasImage的loaded回调这里无需删除旧版上传任务。
|
||||||
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
|
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
|
||||||
|
// 给旧节点renderer注入新版动作:begin/end维护文本history分组与editing mode,正文按同incarnation使用groupId。
|
||||||
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
|
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
|
||||||
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
|
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
|
||||||
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
|
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
|
||||||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
||||||
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||||
|
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
|
||||||
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
||||||
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
||||||
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
||||||
|
// 分支直接在当前canvas按方向偏移创建文本,再同一事务创建连线;与旧版弹类型菜单的行为不同。
|
||||||
branch: (id, direction) => {
|
branch: (id, direction) => {
|
||||||
const node = projectionCache.current!.getNode(id); if (!node) return;
|
const node = projectionCache.current!.getNode(id); if (!node) return;
|
||||||
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
|
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
|
||||||
@@ -249,19 +312,23 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
},
|
},
|
||||||
|
|
||||||
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
|
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
|
||||||
|
// 曲线编辑同样捕获connectionGesture token;active暂为空集合,连线renderer在此不从run状态获取执行动画。
|
||||||
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
|
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
|
||||||
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
|
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
|
||||||
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
||||||
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||||||
|
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
|
||||||
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
||||||
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||||
},
|
},
|
||||||
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||||
|
// 临时path只更新session;正式修改把data和path分开命令,在同一个事务提交,避免把路径写进业务data。
|
||||||
patch: (id, value, transient) => {
|
patch: (id, value, transient) => {
|
||||||
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
|
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
|
||||||
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||||||
const path = transformPath((value.path ?? null) as Json, t, true);
|
const path = transformPath((value.path ?? null) as Json, t, true);
|
||||||
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
|
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
|
||||||
|
// 工具栏可能传回整个显示data;只有显式含path且与显示路径不同才提交connection.path,避免无意重写。
|
||||||
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
|
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
|
||||||
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
|
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
|
||||||
const { path: ignored, ...rest } = value; void ignored;
|
const { path: ignored, ...rest } = value; void ignored;
|
||||||
@@ -269,8 +336,15 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
},
|
},
|
||||||
|
|
||||||
}),[engine,viewId,report]);
|
}),[engine,viewId,report]);
|
||||||
|
// 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。
|
||||||
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
||||||
|
// 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。
|
||||||
if (!liveView) return <p role="status">画布已关闭</p>;
|
if (!liveView) return <p role="status">画布已关闭</p>;
|
||||||
|
// surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。
|
||||||
|
// 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。
|
||||||
|
// ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。
|
||||||
|
// 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。
|
||||||
|
// 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。
|
||||||
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
||||||
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
|
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
|
||||||
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
|
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
|
||||||
@@ -296,6 +370,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
||||||
onNodeDragStart={(event, node, nodes) => {
|
onNodeDragStart={(event, node, nodes) => {
|
||||||
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
||||||
|
// 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。
|
||||||
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
||||||
const point = 'clientX' in event ? event : event.changedTouches[0];
|
const point = 'clientX' in event ? event : event.changedTouches[0];
|
||||||
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
|
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
|
||||||
@@ -305,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
|
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
|
||||||
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
|
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
|
||||||
onNodeDragStop={event => {
|
onNodeDragStop={event => {
|
||||||
|
// 仅结束匹配本视图和token的拖动,迟到dragStop不提交当前其它手势。
|
||||||
const saved = drag.current; drag.current = null; pointerStart.current = null;
|
const saved = drag.current; drag.current = null; pointerStart.current = null;
|
||||||
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
|
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
|
||||||
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
||||||
@@ -318,6 +394,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
||||||
|
// 深复制点击时捕获选择身份,异步完成只更新同engine/view的面板;runtime负责进度与已加入内容。
|
||||||
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
|
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
|
||||||
setCopying(true); setError("");
|
setCopying(true); setError("");
|
||||||
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
|
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
|
||||||
@@ -339,9 +416,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
|
||||||
|
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
|
||||||
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
||||||
const [handoffViewport]=useState(initialViewport);
|
const [handoffViewport]=useState(initialViewport);
|
||||||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||||||
|
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
|
||||||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
||||||
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 旧版画布树的状态与操作协调Hook。documents按boardId保存已加载快照,views把嵌套子画布投影为当前ReactFlow场景。
|
||||||
|
* 每个board有独立CanvasSync writer,本地历史和服务端结构事务共用两个栈,成员迁移另用串行内存队列。
|
||||||
|
* 该实现保留旧快照协议与本地草稿恢复,不等同于新版engine的统一提交根、持久ledger和恢复状态机。
|
||||||
|
* 调用方应按根/工作区身份重挂载;refs提供异步操作读取的最新状态,React state负责发布界面变化。
|
||||||
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { EdgeChange, NodeChange } from "@xyflow/react";
|
import type { EdgeChange, NodeChange } from "@xyflow/react";
|
||||||
@@ -8,20 +14,34 @@ import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
|
|||||||
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
|
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
|
||||||
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
|
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
|
||||||
|
|
||||||
|
/** 命令返回的快照额外携带board_id,用于把多画布结果安装回各自writer。 */
|
||||||
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
|
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
|
||||||
|
/** 兼容多个旧命令端点的结果包络,可能返回父/子/多快照、事务身份、归档列表和版本;各使用路径再解码快照。 */
|
||||||
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
|
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
|
||||||
|
/** 本地历史保存操作前整份画布,可附关联画布;pendingMove标记尚未上传图片的跨画布移动,便于后来按服务端归属撤销。 */
|
||||||
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
|
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
|
||||||
|
/** 服务端结构事务历史保存可重放的路径/负载、事务ID和参与画布;撤销请求服务端undo,重做重新执行原命令。 */
|
||||||
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
|
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
|
||||||
|
/** 两种历史条目共用undo/redo栈,通过是否有id字段区分;不是每次pointermove都直接创建条目。 */
|
||||||
type Entry = LocalHistory | TransactionHistory;
|
type Entry = LocalHistory | TransactionHistory;
|
||||||
|
/** 成员归属迁移的FIFO任务;run在准备后捕获固定body/key供不确定失败重试,refresh表示确定冲突后需刷新基线。 */
|
||||||
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
|
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
|
||||||
|
/** 只为一次reducer运算建立临时空历史,借其past是否变化判断该动作是否应进入本Hook自己的历史栈。 */
|
||||||
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
|
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
|
||||||
|
|
||||||
|
/** initial建立根画布,snapshot决定是否连接服务端writer;paused阻止保存/独占事务等准入,migration额外限制旧写入。
|
||||||
|
* parentBoardId用于补读祖先快照;导出、跨分区移动和事务可能按需加载更多画布。 */
|
||||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
|
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
|
||||||
|
// 有迁移上下文时先关旧编辑入口,等迁移协调组件取得共享租约再开放;flush只有迁移准备阶段临时启用。
|
||||||
const migrationGate=useRef(!!migration);
|
const migrationGate=useRef(!!migration);
|
||||||
const migrationFlush=useRef(false);
|
const migrationFlush=useRef(false);
|
||||||
const [migrationHeld,setMigrationState]=useState(!!migration);
|
const [migrationHeld,setMigrationState]=useState(!!migration);
|
||||||
|
/** 同步检查门闩和账号身份,事件处理无需等待React的migrationHeld重新渲染。 */
|
||||||
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
|
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
|
||||||
|
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
|
||||||
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
|
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
|
||||||
|
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
|
||||||
|
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
|
||||||
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
|
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
|
||||||
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||||
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
|
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
|
||||||
@@ -29,33 +49,42 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
const value=await requestAPI<T>(path,{...options,headers:Object.fromEntries(headers)});
|
const value=await requestAPI<T>(path,{...options,headers:Object.fromEntries(headers)});
|
||||||
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
|
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
|
||||||
};
|
};
|
||||||
|
// documents是已加载board的模型表,current立即跟随发布供异步回调读取;status/error是旧writer汇总显示值。
|
||||||
const [documents, setDocuments] = useState(new Map([[root, initial]]));
|
const [documents, setDocuments] = useState(new Map([[root, initial]]));
|
||||||
const current = useRef(documents); current.current = documents;
|
const current = useRef(documents); current.current = documents;
|
||||||
const [status, setStatus] = useState("saved");
|
const [status, setStatus] = useState("saved");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
|
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
|
||||||
|
// capability缓存查询Promise;members是实际内存队列,memberJobs只是供React显示的数组副本。
|
||||||
const memberCapability = useRef<Promise<boolean> | null>(null);
|
const memberCapability = useRef<Promise<boolean> | null>(null);
|
||||||
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
|
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
|
||||||
|
/** 仅挂载期间发布队列副本;任务对象仍共享引用,修改error等字段后需显式通知。 */
|
||||||
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
|
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
|
||||||
|
/** 拖动期间延后自动保存,结束时发布新Map触发防抖重新评估;不在这里执行成员归属变化。 */
|
||||||
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
|
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
|
||||||
|
/** 成员任务涉及的节点暂不能再被普通动作改动;队列非空时也保护子图入口几何及结构,避免请求基准漂移。 */
|
||||||
function protectedNodes(boardId: string) {
|
function protectedNodes(boardId: string) {
|
||||||
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
|
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
|
||||||
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
|
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
const [pendingCommand, setPendingCommand] = useState(false);
|
const [pendingCommand, setPendingCommand] = useState(false);
|
||||||
|
// 不确定命令失败时保留同一execute闭包,pendingCommand驱动UI;locked只管当前独占异步操作。
|
||||||
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
|
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
|
||||||
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
|
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
|
||||||
|
// writer持每画布ACK基线与保存队列,loading去重子画布读取;二者都不是documents的替代存储。
|
||||||
const writers = useRef(new Map<string, CanvasSync>());
|
const writers = useRef(new Map<string, CanvasSync>());
|
||||||
const loading = useRef(new Set<string>());
|
const loading = useRef(new Set<string>());
|
||||||
const [retryLoad, setRetryLoad] = useState(0);
|
const [retryLoad, setRetryLoad] = useState(0);
|
||||||
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
|
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
|
||||||
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
|
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
|
||||||
|
/** 以当前服务端快照筛选有内容差异的本地恢复候选,按board记录,等待用户决定;不证明可安全覆盖模型。 */
|
||||||
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
|
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
|
||||||
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
|
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
|
||||||
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
|
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
|
||||||
}
|
}
|
||||||
|
/** 可编辑时按选择替换本地文档或采用现状,移除恢复候选和草稿记录;远端写入仍走后续writer。 */
|
||||||
function resolveRecovery(boardId: string, restore: boolean) {
|
function resolveRecovery(boardId: string, restore: boolean) {
|
||||||
if(migrationBlocked())return;
|
if(migrationBlocked())return;
|
||||||
const draft = pendingRecovery.current.get(boardId);
|
const draft = pendingRecovery.current.get(boardId);
|
||||||
@@ -63,12 +92,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
||||||
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
||||||
}
|
}
|
||||||
|
// 两个ref数组作为历史栈,boardAliases记录重做子图创建后新旧boardID对应;tick用于仅栈变化时通知UI。
|
||||||
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
|
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
|
||||||
const boardAliases = useRef(new Map<string, string>());
|
const boardAliases = useRef(new Map<string, string>());
|
||||||
const [, tick] = useState(0);
|
const [, tick] = useState(0);
|
||||||
|
// 根据根与documents派生投影索引,nodeRefs/edgeRefs把场景ID映射回所属board、本地ID和坐标变换。
|
||||||
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
|
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
|
||||||
const view = useRef(views); view.current = views;
|
const view = useRef(views); view.current = views;
|
||||||
|
/** 先更新同步ref,再在挂载期间发布React state;等待中的操作可立即读取新Map。 */
|
||||||
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
|
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
|
||||||
|
/** 将服务端快照作为writer基线:已有writer采用新ACK,否则在真实快照模式建立writer;这里只安装,不发保存。 */
|
||||||
function install(boardId: string, value: CanvasSnapshot) {
|
function install(boardId: string, value: CanvasSnapshot) {
|
||||||
const writer = writers.current.get(boardId);
|
const writer = writers.current.get(boardId);
|
||||||
if (writer) writer.adopt(value);
|
if (writer) writer.adopt(value);
|
||||||
@@ -80,25 +113,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
// Initialization never writes a snapshot to the server.
|
// Initialization never writes a snapshot to the server.
|
||||||
if (snapshot && !writers.current.has(root)) install(root, snapshot);
|
if (snapshot && !writers.current.has(root)) install(root, snapshot);
|
||||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||||
|
/** 普通单画布动作入口:检查门闩,经reducer计算新文档,保留成员迁移保护字段,再按需要记历史并发布。 */
|
||||||
const update = (boardId: string, action: CanvasAction) => {
|
const update = (boardId: string, action: CanvasAction) => {
|
||||||
if (migrationBlocked() || locked.current || retryOperation.current) return;
|
if (migrationBlocked() || locked.current || retryOperation.current) return;
|
||||||
const before = current.current.get(boardId); if (!before) return;
|
const before = current.current.get(boardId); if (!before) return;
|
||||||
const result = canvasReducer(history(before), action);
|
const result = canvasReducer(history(before), action);
|
||||||
const protectedIDs = protectedNodes(boardId);
|
const protectedIDs = protectedNodes(boardId);
|
||||||
|
// 迁移中的普通节点只允许选中变化;未作为移动目标的子图入口保留原几何、subgraph/ports/presentation。
|
||||||
|
// reducer删掉的受保护节点也补回,避免异步成员请求到来时对象已被本地删除。
|
||||||
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
|
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
|
||||||
const prior = before.nodes.find((item) => item.id === node.id);
|
const prior = before.nodes.find((item) => item.id === node.id);
|
||||||
if (!prior || !protectedIDs.has(node.id)) return node;
|
if (!prior || !protectedIDs.has(node.id)) return node;
|
||||||
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
|
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
|
||||||
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
|
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
|
||||||
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
|
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
|
||||||
|
// reducer产生历史才入undo,普通更新路径最多留40项并清redo;其它事务/刷新路径不一定使用同一截断逻辑。
|
||||||
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
|
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
|
||||||
publish(new Map(current.current).set(boardId, result.present));
|
publish(new Map(current.current).set(boardId, result.present));
|
||||||
};
|
};
|
||||||
|
/** 默认对根发动作,也允许调用方显式指定已加载board。 */
|
||||||
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
|
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
|
||||||
|
/** 从投影节点ID解析实际owner,再用原始节点ID构造动作,避免把场景复合ID写回快照。 */
|
||||||
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
|
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
|
||||||
|
/** 按owner分组ReactFlow节点变化;忽略add/replace,将场景位置和尺寸还原为所属board局部坐标。 */
|
||||||
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
|
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
|
||||||
if (migrationBlocked() || locked.current || retryOperation.current) return;
|
if (migrationBlocked() || locked.current || retryOperation.current) return;
|
||||||
const groups = new Map<string, NodeChange<CanvasNode>[]>();
|
const groups = new Map<string, NodeChange<CanvasNode>[]>();
|
||||||
|
// 同批父分区也在移动时先计算调整后的原点,避免对子节点重复施加父位移;尺寸再除投影scale。
|
||||||
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
|
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (change.type === "add" || change.type === "replace") continue;
|
if (change.type === "add" || change.type === "replace") continue;
|
||||||
@@ -106,6 +147,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
|
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
|
||||||
const next = { ...change, id: ref.id };
|
const next = { ...change, id: ref.id };
|
||||||
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
|
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
|
||||||
|
// ResizeObserver只更新测量信息;非显式resizing不得把测量宽高当模型尺寸写回。
|
||||||
if (next.type === "dimensions") {
|
if (next.type === "dimensions") {
|
||||||
// ResizeObserver measurements are editor state. Only an explicit resize
|
// ResizeObserver measurements are editor state. Only an explicit resize
|
||||||
// gesture may write node dimensions back to the owning snapshot.
|
// gesture may write node dimensions back to the owning snapshot.
|
||||||
@@ -116,6 +158,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
}
|
}
|
||||||
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
|
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
|
||||||
};
|
};
|
||||||
|
/** 按edgeRefs分组选择/删除等变化,忽略add/replace;最终update仍统一检查写入准入。 */
|
||||||
const edgesChanged = (changes: EdgeChange[]) => {
|
const edgesChanged = (changes: EdgeChange[]) => {
|
||||||
const groups = new Map<string, EdgeChange[]>();
|
const groups = new Map<string, EdgeChange[]>();
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
@@ -124,13 +167,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
}
|
}
|
||||||
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
|
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
|
||||||
};
|
};
|
||||||
|
/** 曲线路径从场景坐标反变换回owner局部空间,再派发edge-data;transient交reducer决定历史语义。 */
|
||||||
const patchEdge = (id: string, data: EdgeData, transient = false) => {
|
const patchEdge = (id: string, data: EdgeData, transient = false) => {
|
||||||
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
|
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
|
||||||
};
|
};
|
||||||
|
/** 遍历已加载文档,只有目标owner/localID被选中,其余节点取消选择;不直接操作投影数组。 */
|
||||||
const select = (id: string) => {
|
const select = (id: string) => {
|
||||||
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
|
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
|
||||||
};
|
};
|
||||||
|
/** 读取并解码一个board的旧v2快照,可传取消信号;api层仍检查迁移账号身份。 */
|
||||||
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
|
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
|
||||||
|
/** 查询服务端是否支持成员迁移同时提交坐标;演示为false,成功结果复用,失败清缓存供按需重试。 */
|
||||||
function supportsMemberPositions(): Promise<boolean> {
|
function supportsMemberPositions(): Promise<boolean> {
|
||||||
if (!snapshot) return Promise.resolve(false);
|
if (!snapshot) return Promise.resolve(false);
|
||||||
if (!memberCapability.current) {
|
if (!memberCapability.current) {
|
||||||
@@ -141,9 +188,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
}
|
}
|
||||||
return memberCapability.current;
|
return memberCapability.current;
|
||||||
}
|
}
|
||||||
|
// 能力预取失败不视作确定不支持,真正成员操作仍可重新请求。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
|
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
|
||||||
}, [root, workspace, !!snapshot]);
|
}, [root, workspace, !!snapshot]);
|
||||||
|
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
|
||||||
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
|
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
|
||||||
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
||||||
let document = current.current.get(boardId);
|
let document = current.current.get(boardId);
|
||||||
@@ -153,14 +202,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
}
|
}
|
||||||
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
|
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
|
||||||
}
|
}
|
||||||
|
/** 独占期间完整加载根下可达子图,然后收集导出Map;不会把已加载但不可达的祖先或其它分支混入导出。 */
|
||||||
async function exportDocuments() {
|
async function exportDocuments() {
|
||||||
return exclusive(async () => {
|
return exclusive(async () => {
|
||||||
await ensure(root, true);
|
await ensure(root, true);
|
||||||
const reachable = new Map<string, CanvasDocument>();
|
const reachable = new Map<string, CanvasDocument>();
|
||||||
|
/** 可达集合兼作去重;ensure已验证递归关系,此处只遍历当前已加载文档。 */
|
||||||
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
|
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
|
||||||
collect(root); return reachable;
|
collect(root); return reachable;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 打开子画布时向上读取元数据与快照,补齐祖先关系;alive阻止离页后的采用,不在本effect中abort请求。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || !parentBoardId) return;
|
if (!snapshot || !parentBoardId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -178,6 +230,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
// Root identity is stable until this editor unmounts.
|
// Root identity is stable until this editor unmounts.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [parentBoardId, root, workspace]);
|
}, [parentBoardId, root, workspace]);
|
||||||
|
// 场景可见且未折叠的子图按需异步读取,loading去重;回包时重新确认仍被当前文档引用。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const [boardId, document] of documents) for (const node of document.nodes) {
|
for (const [boardId, document] of documents) for (const node of document.nodes) {
|
||||||
if (!view.current.boards.has(boardId)) continue;
|
if (!view.current.boards.has(boardId)) continue;
|
||||||
@@ -192,6 +245,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
// Values and writers are scoped to this mounted root/workspace.
|
// Values and writers are scoped to this mounted root/workspace.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [documents, retryLoad]);
|
}, [documents, retryLoad]);
|
||||||
|
// 文档变化防抖:250ms本地备份、800ms远端保存。恢复待决画布跳过,防止覆盖用户尚未选择的草稿。
|
||||||
|
// 远端另等独占锁、命令重试、成员任务和拖动结束;本地备份不需要等这些普通操作全部结束。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused || migrationBlocked()) return;
|
if (paused || migrationBlocked()) return;
|
||||||
const backup = setTimeout(() => {
|
const backup = setTimeout(() => {
|
||||||
@@ -208,7 +263,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
}, 800);
|
}, 800);
|
||||||
return () => { clearTimeout(backup); clearTimeout(save); };
|
return () => { clearTimeout(backup); clearTimeout(save); };
|
||||||
}, [documents, paused, workspace, memberJobs, migrationHeld]);
|
}, [documents, paused, workspace, memberJobs, migrationHeld]);
|
||||||
|
// pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */
|
||||||
const backup = () => {
|
const backup = () => {
|
||||||
if (pause.current || migrationBlocked()) return;
|
if (pause.current || migrationBlocked()) return;
|
||||||
for (const [boardId, document] of current.current) {
|
for (const [boardId, document] of current.current) {
|
||||||
@@ -219,6 +276,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
window.addEventListener("pagehide", backup);
|
window.addEventListener("pagehide", backup);
|
||||||
return () => { window.removeEventListener("pagehide", backup); backup(); };
|
return () => { window.removeEventListener("pagehide", backup); backup(); };
|
||||||
}, [workspace]);
|
}, [workspace]);
|
||||||
|
/** 串行排空各writer,任一恢复待决或writer错误就失败;deferMemberPositions保留待移动节点原ACK坐标。
|
||||||
|
* 支持原子坐标的成员接口会在归属事务里提交新位置,避免先把新位置单独写入旧owner。 */
|
||||||
async function flush(deferMemberPositions = false) {
|
async function flush(deferMemberPositions = false) {
|
||||||
for (const [boardId, document] of current.current) {
|
for (const [boardId, document] of current.current) {
|
||||||
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
|
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
|
||||||
@@ -228,6 +287,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 迁移前必须已关门,无事务/成员/拖动/加载,且恢复已处理;加载整棵迁移树并核查上传占位。
|
||||||
|
* 先备份本页,再临时放行PATCH排空旧writer;所有writer确认无待保存内容后把备份标为clean。 */
|
||||||
async function prepareMigration(projectRoot:string){
|
async function prepareMigration(projectRoot:string){
|
||||||
if(!migrationGate.current)throw new Error('migration_gate_required');
|
if(!migrationGate.current)throw new Error('migration_gate_required');
|
||||||
if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
|
if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
|
||||||
@@ -240,12 +301,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
for(const[boardId,document]of current.current){
|
for(const[boardId,document]of current.current){
|
||||||
const writer=writers.current.get(boardId);
|
const writer=writers.current.get(boardId);
|
||||||
const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
|
const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
|
||||||
|
// 本地另有不同恢复内容时转为用户选择,不能用当前内存直接覆盖它后迁移。
|
||||||
if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
|
if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
|
||||||
const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
|
const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
|
||||||
}
|
}
|
||||||
// Preserve the current view even if it closes while PATCH is in flight.
|
// Preserve the current view even if it closes while PATCH is in flight.
|
||||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
|
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
|
||||||
}
|
}
|
||||||
|
// 只在flush的try/finally窗口放开旧PATCH,其它写命令仍被api护栏拒绝。
|
||||||
migrationFlush.current=true;
|
migrationFlush.current=true;
|
||||||
try{await flush();}finally{migrationFlush.current=false;}
|
try{await flush();}finally{migrationFlush.current=false;}
|
||||||
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||||
@@ -255,15 +318,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
|
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 旧版内存独占执行器,阻断冲突准入并发布busy;异常记error后继续抛出,finally可靠释放锁。
|
||||||
|
* 它不是跨标签页锁,也不提供操作的整批服务端回滚。 */
|
||||||
async function exclusive<T>(operation: () => Promise<T>) {
|
async function exclusive<T>(operation: () => Promise<T>) {
|
||||||
if (migrationBlocked() || locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
|
if (migrationBlocked() || locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
|
||||||
locked.current = true; setBusy(true);
|
locked.current = true; setBusy(true);
|
||||||
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
|
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
|
||||||
finally { locked.current = false; if (mounted.current) setBusy(false); }
|
finally { locked.current = false; if (mounted.current) setBusy(false); }
|
||||||
}
|
}
|
||||||
|
/** 服务端快照更新时恢复本地选中标记,避免结构事务结果抹掉当前视图选择。 */
|
||||||
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
|
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
|
||||||
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
|
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
|
||||||
}
|
}
|
||||||
|
/** 汇总命令返回的各类快照,按boardID去重,基于旧ACK做三方合并再安装新基线。 */
|
||||||
function accept(result: CanvasCommandResult) {
|
function accept(result: CanvasCommandResult) {
|
||||||
const next = new Map(current.current);
|
const next = new Map(current.current);
|
||||||
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
|
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
|
||||||
@@ -272,6 +339,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
|
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
|
||||||
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
|
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
|
||||||
}
|
}
|
||||||
|
// 子图归档后,将仍在内存上传的节点及相关边投到父画布,避免服务端快照中没有的占位被丢弃。
|
||||||
|
// 其余已归档文档及writer从当前表移除,不在这里删除上传任务的原文件。
|
||||||
for (const id of result.archived_board_ids || []) {
|
for (const id of result.archived_board_ids || []) {
|
||||||
const local=current.current.get(id);
|
const local=current.current.get(id);
|
||||||
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
|
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
|
||||||
@@ -286,6 +355,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
publish(next);
|
publish(next);
|
||||||
return [...new Set(values.map((value) => value.board_id))];
|
return [...new Set(values.map((value) => value.board_id))];
|
||||||
}
|
}
|
||||||
|
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
|
||||||
|
* execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */
|
||||||
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
||||||
if (retryOperation.current) throw new Error("canvas_busy");
|
if (retryOperation.current) throw new Error("canvas_busy");
|
||||||
return exclusive(async () => {
|
return exclusive(async () => {
|
||||||
@@ -295,21 +366,25 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
await flush();
|
await flush();
|
||||||
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
|
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
|
||||||
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
|
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
|
||||||
|
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
|
||||||
const execute = async (): Promise<CanvasCommandResult> => {
|
const execute = async (): Promise<CanvasCommandResult> => {
|
||||||
try {
|
try {
|
||||||
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
|
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
|
||||||
const changed = accept(result);
|
const changed = accept(result);
|
||||||
|
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
|
||||||
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
|
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
|
||||||
if (!changed.length && result.canvas_version) {
|
if (!changed.length && result.canvas_version) {
|
||||||
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
|
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
|
||||||
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
|
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
|
||||||
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
|
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
|
||||||
}
|
}
|
||||||
|
// record=false用于undo/redo等已有历史流程,避免将历史遍历再次当新操作压栈。
|
||||||
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
|
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
|
||||||
retryOperation.current = null; setPendingCommand(false); setError("");
|
retryOperation.current = null; setPendingCommand(false); setError("");
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = error instanceof Error ? error.message : "request_failed";
|
const code = error instanceof Error ? error.message : "request_failed";
|
||||||
|
// 网络/上游/解码等不确定失败保留execute并阻断普通编辑;确定拒绝清重试入口,错误仍交调用方处理。
|
||||||
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
|
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
|
||||||
else { retryOperation.current = null; setPendingCommand(false); }
|
else { retryOperation.current = null; setPendingCommand(false); }
|
||||||
throw error;
|
throw error;
|
||||||
@@ -320,12 +395,15 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
}
|
}
|
||||||
// Ownership changes pause autosaves, not unrelated local editing. Server
|
// Ownership changes pause autosaves, not unrelated local editing. Server
|
||||||
// acknowledgements merge against the persisted base, preserving intervening edits.
|
// acknowledgements merge against the persisted base, preserving intervening edits.
|
||||||
|
/** 成员队列只跑队首;归属移动期间暂停autosave,允许不相关本地编辑,结果按捕获ACK合并。
|
||||||
|
* 只有成功才shift并resolve;失败任务留队首供重试,Promise不在失败时假装完成。 */
|
||||||
async function runMembers() {
|
async function runMembers() {
|
||||||
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
|
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
|
||||||
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
|
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
|
||||||
try {
|
try {
|
||||||
if (!job.run) {
|
if (!job.run) {
|
||||||
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
|
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
|
||||||
|
// 412等确定冲突后的重新准备:读参与快照,先三方合并本地变化,再更新各writer基线。
|
||||||
if (job.refresh) {
|
if (job.refresh) {
|
||||||
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
|
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
|
||||||
const next = new Map(current.current);
|
const next = new Map(current.current);
|
||||||
@@ -336,6 +414,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
for (const [id, remote] of refreshed) install(id, remote);
|
for (const [id, remote] of refreshed) install(id, remote);
|
||||||
publish(next); job.refresh = false;
|
publish(next); job.refresh = false;
|
||||||
}
|
}
|
||||||
|
// 支持坐标时flush延后待移动节点位置;否则先按旧保存行为排空,再捕获参与文档作为合并基线。
|
||||||
const atomic = await supportsMemberPositions();
|
const atomic = await supportsMemberPositions();
|
||||||
await flush(atomic === true);
|
await flush(atomic === true);
|
||||||
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
|
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
|
||||||
@@ -344,17 +423,22 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
|
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
|
||||||
return [id, node.position];
|
return [id, node.position];
|
||||||
}));
|
}));
|
||||||
|
// 请求内容含参与版本和可选局部坐标;创建job.run后重试不得换body或幂等key,除非确定原请求未应用。
|
||||||
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
|
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
|
||||||
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
|
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
|
||||||
const key = crypto.randomUUID(), historyIndex = undo.current.length;
|
const key = crypto.randomUUID(), historyIndex = undo.current.length;
|
||||||
|
/** 一次准备生成的可重试闭包;要求回执覆盖所有参与画布,不能凭缺失快照移动本地owner。 */
|
||||||
job.run = async () => {
|
job.run = async () => {
|
||||||
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
|
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
|
||||||
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
|
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
|
||||||
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
|
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
|
||||||
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
|
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
|
||||||
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
|
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
|
||||||
|
/** 移动结果已含新坐标,合并前只把源节点暂存位置还原到基线,避免重复施加坐标变化。 */
|
||||||
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
|
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
|
||||||
|
/** 参与画布基于原ACK+归一化本地内容+返回快照重放,未参与文档原样保留。 */
|
||||||
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
|
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
|
||||||
|
// 请求期间新增本地历史也基于新owner结果调整;开始前历史和服务端事务条目保持原语义。
|
||||||
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
|
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
|
||||||
const next = new Map(current.current);
|
const next = new Map(current.current);
|
||||||
for (const [id, value] of values) {
|
for (const [id, value] of values) {
|
||||||
@@ -369,6 +453,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
for (const [id, value] of values) install(id, value);
|
for (const [id, value] of values) install(id, value);
|
||||||
publish(next);
|
publish(next);
|
||||||
undo.current = rebasedHistory;
|
undo.current = rebasedHistory;
|
||||||
|
// 将迁移事务插回开始时的historyIndex,保留请求期间后来编辑的顺序,并清除旧redo。
|
||||||
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
|
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -378,6 +463,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
job.error = error instanceof Error ? error.message : "request_failed";
|
job.error = error instanceof Error ? error.message : "request_failed";
|
||||||
// A definite rejection performed zero writes. A retry may prepare fresh
|
// A definite rejection performed zero writes. A retry may prepare fresh
|
||||||
// versions; ambiguous transport failures must retain the exact body/key.
|
// versions; ambiguous transport failures must retain the exact body/key.
|
||||||
|
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
|
||||||
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
|
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
|
||||||
showMembers();
|
showMembers();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -385,24 +471,31 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
if (members.current.length && !members.current[0].error) void runMembers();
|
if (members.current.length && !members.current[0].error) void runMembers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */
|
||||||
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
|
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
|
||||||
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
|
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/** 将上传资源/尺寸绑定到所有当前文档及本地历史,避免撤销恢复旧Blob占位。
|
||||||
|
* 无asset时保留pendingUpload;尺寸仅在纯图片仍为320×240默认值时采用,不覆盖用户已调整大小。 */
|
||||||
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
|
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
|
||||||
if(migrationBlocked())return;
|
if(migrationBlocked())return;
|
||||||
|
/** 只匹配pendingUpload身份,移除或保留占位标记,并按文档schema选择是否写assetId。 */
|
||||||
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
|
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
|
||||||
if (node.pendingUpload !== id) return node;
|
if (node.pendingUpload !== id) return node;
|
||||||
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
|
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
|
||||||
const {pendingUpload,...ready}=node;
|
const {pendingUpload,...ready}=node;
|
||||||
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
|
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
|
||||||
}) });
|
}) });
|
||||||
|
/** 仅改本地快照历史(含related),服务端事务条目仍由服务端重放解释。 */
|
||||||
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
|
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
|
||||||
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
|
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
|
||||||
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
|
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
|
||||||
}
|
}
|
||||||
|
/** 未上传且无关联边的图片可先在内存跨分区移动;校验容量/ID后按两owner原点和scale换算。
|
||||||
|
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
|
||||||
function movePendingImage(source: string, target: string, id: string) {
|
function movePendingImage(source: string, target: string, id: string) {
|
||||||
if(migrationBlocked())throw new Error("canvas_busy");
|
if(migrationBlocked())throw new Error("canvas_busy");
|
||||||
const from = current.current.get(source)!, to = current.current.get(target)!;
|
const from = current.current.get(source)!, to = current.current.get(target)!;
|
||||||
@@ -413,6 +506,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
|
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
|
||||||
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
|
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
|
||||||
}
|
}
|
||||||
|
/** 独占等待writer停止后读新快照并三方合并;内容指纹改变才记本地历史,再安装新ACK。 */
|
||||||
async function refresh(boardId: string) {
|
async function refresh(boardId: string) {
|
||||||
return exclusive(async () => {
|
return exclusive(async () => {
|
||||||
const writer = writers.current.get(boardId); if (!writer) return;
|
const writer = writers.current.get(boardId); if (!writer) return;
|
||||||
@@ -423,6 +517,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/** 显式采用生成结果前,针对目标节点把基线text/src/assetId/ai对齐到远端,再合并其它本地编辑。
|
||||||
|
* 合并结果先排空保存,后续apply命令才能在明确版本上覆盖目标;此方法本身不发送生成结果采用请求。 */
|
||||||
async function prepareApply(boardId: string, nodeId: string) {
|
async function prepareApply(boardId: string, nodeId: string) {
|
||||||
await exclusive(async () => {
|
await exclusive(async () => {
|
||||||
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
|
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
|
||||||
@@ -436,12 +532,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
setError("");
|
setError("");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/** 查看栈顶并检查准入,成功后才移动栈项;本地条目替换before,事务条目调用服务端undo或重做。
|
||||||
|
* 重做可能创建新子board,使用alias将后续历史引用转到新身份,避免继续操作已归档旧子图。 */
|
||||||
async function undoAction(isRedo = false) {
|
async function undoAction(isRedo = false) {
|
||||||
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
|
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
|
||||||
const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
|
const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
|
||||||
|
/** 沿别名链解析最新boardID并防循环;只用于历史重放,不篡改任意元素ID。 */
|
||||||
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
|
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
|
||||||
|
/** 重做本地快照时更新子图board引用,其它节点内容保留。 */
|
||||||
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
|
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
|
||||||
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
|
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
|
||||||
|
// 待上传图片若已存在于writer基线,纯本地快照切换已不够,需按归属路径逐跳请求成员移动后再转栈。
|
||||||
if (!("id" in entry) && entry.pendingMove) {
|
if (!("id" in entry) && entry.pendingMove) {
|
||||||
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
|
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
|
||||||
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
|
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
|
||||||
@@ -452,6 +553,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
stack.pop();destination.push(entry);tick((v)=>v+1);return;
|
stack.pop();destination.push(entry);tick((v)=>v+1);return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
|
||||||
if ("id" in entry) {
|
if ("id" in entry) {
|
||||||
if (isRedo) {
|
if (isRedo) {
|
||||||
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
|
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
|
||||||
@@ -464,6 +566,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
|
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
|
||||||
stack.pop(); destination.push(entry);
|
stack.pop(); destination.push(entry);
|
||||||
}
|
}
|
||||||
|
// 普通本地历史用当前文档作为反向条目,连同related画布一起发布,保证下一次redo可还原。
|
||||||
} else {
|
} else {
|
||||||
const before = current.current.get(entry.boardId); if (!before) return;
|
const before = current.current.get(entry.boardId); if (!before) return;
|
||||||
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
|
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
|
||||||
@@ -472,6 +575,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
}
|
}
|
||||||
tick((v) => v + 1);
|
tick((v) => v + 1);
|
||||||
}
|
}
|
||||||
|
/** 重试普通writer并触发子图读取effect;不在这里自动重发成员任务或未确认command,它们有各自入口。 */
|
||||||
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
|
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
|
||||||
|
// 对外暴露投影、动作、恢复、事务及writer引用,旧版UI依赖此组合接口;busy合并独占、待确认命令与迁移门闩。
|
||||||
|
// transactionBusy/canUndo/canRedo反映成员队列与栈是否有项,最终undoAction仍再次检查实际准入。
|
||||||
return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 服务端已证明 applied、但本地尚未完成 canonical 接纳时的恢复测试。
|
||||||
|
* 模拟 codec 暂不可用、新远端编辑、持久化失败及回执过期,验证恢复不会重发已成功操作或提前释放 writer。
|
||||||
|
* 这里使用内存传输和克隆快照存储,覆盖协议协调,不覆盖真实数据库或浏览器存储实现。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
|
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 { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
|
||||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.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 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}};
|
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]);
|
const registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[element]);
|
||||||
|
// 构造可注入 codec、配额和过旧读取故障的服务端/存储环境,记录全部请求用于检查重复写入。
|
||||||
function fixture(){
|
function fixture(){
|
||||||
let server=original,version=1,image,leader;const calls=[],receipts=new Map();
|
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:[]});
|
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++;}};
|
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=>{
|
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'}};}
|
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 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 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});
|
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);}};
|
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});
|
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;
|
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};}
|
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()=>{
|
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();
|
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');
|
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();
|
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()=>{
|
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 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']]));
|
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();
|
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()=>{
|
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();
|
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();
|
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()=>{
|
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');
|
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;
|
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();
|
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()=>{
|
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 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();
|
const session=await recovered.prepareRecovery();assert.deepEqual(session.acknowledgedRequestIds,[request.id]);await session.apply();await recovered.settle();
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 供浏览器存储 runner 调用的真实 IndexedDB 接纳场景,不是 node:test 自动收集的文件。
|
||||||
|
* 先由源项目写入共享字节,再从目标项目授权复用,重开后验证 binding、显示、历史及关闭项目的持久化 pin。
|
||||||
|
*/
|
||||||
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
||||||
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.ts';
|
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.ts';
|
||||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
|
// 每次使用独立数据库;assert 由浏览器 runner 注入,finally 释放 leader/runtime 并请求删库。
|
||||||
export async function assetAdoptionStorageCase(assert){
|
export async function assetAdoptionStorageCase(assert){
|
||||||
const name=`adoption-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'source'},authority='https://assets.test/w',bytes=new Blob(['shared original'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),ref={version:1,authority,digest,asset:{id:'resource',url:`${authority}/resource`,width:2,height:1,bytes:bytes.size,mime_type:'image/png'}},registry=createRegistry(builtinComponents());
|
const name=`adoption-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'source'},authority='https://assets.test/w',bytes=new Blob(['shared original'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),ref={version:1,authority,digest,asset:{id:'resource',url:`${authority}/resource`,width:2,height:1,bytes:bytes.size,mime_type:'image/png'}},registry=createRegistry(builtinComponents());
|
||||||
let storage=createBrowserStorage({name}),leader=await storage.claim(scope),runtime,engine,allowed=true,authorizations=0,reads=0,submits=0,version=1,document=createDocument('target',[{id:'canvas',parent:null}],[]);const receipts=new Map();
|
let storage=createBrowserStorage({name}),leader=await storage.claim(scope),runtime,engine,allowed=true,authorizations=0,reads=0,submits=0,version=1,document=createDocument('target',[{id:'canvas',parent:null}],[]);const receipts=new Map();
|
||||||
|
// 简化源项目 image 与 depot cut;目标引擎会自行生成完整的可恢复状态根。
|
||||||
const image=revision=>({formatVersion:1,rootRevision:revision,localRevision:revision,payload:{source:revision}}),cut=pins=>({version:1,authority,complete:true,pins,writes:[]});
|
const image=revision=>({formatVersion:1,rootRevision:revision,localRevision:revision,payload:{source:revision}}),cut=pins=>({version:1,authority,complete:true,pins,writes:[]});
|
||||||
|
// 源字节已经预热,authority 仍授权;任何再次下载或进入上传流程的调用都直接报错。
|
||||||
const assets={authority:{scope:authority,async resolve(){authorizations++;if(!allowed)throw Error('denied');return ref;},async read(){reads++;throw Error('cache miss should not download');}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/assets/${id}`,async upload(){throw Error('existing bytes uploaded');},async query(){throw Error('upload queried');},accept(){throw Error('upload accepted');}};
|
const assets={authority:{scope:authority,async resolve(){authorizations++;if(!allowed)throw Error('denied');return ref;},async read(){reads++;throw Error('cache miss should not download');}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/assets/${id}`,async upload(){throw Error('existing bytes uploaded');},async query(){throw Error('upload queried');},accept(){throw Error('upload accepted');}};
|
||||||
|
// 简化目标文档服务:提交应用变化并保存 canonical 回执,版本逐次递增。
|
||||||
const persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){submits++;document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
const persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){submits++;document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
||||||
|
// 重新装配 WorkspaceRuntime 并打开目标项目;用于证明数据来自新存储连接的恢复,而非旧内存状态。
|
||||||
const open=async()=>{runtime=createWorkspaceRuntime({accountId:'a',workspaceId:'w',authEpoch:1,registry,storage,isCurrentIdentity:()=>true,adapters:()=>({assets,persistence})});engine=await runtime.open(createDocument('target',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));};
|
const open=async()=>{runtime=createWorkspaceRuntime({accountId:'a',workspaceId:'w',authEpoch:1,registry,storage,isCurrentIdentity:()=>true,adapters:()=>({assets,persistence})});engine=await runtime.open(createDocument('target',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));};
|
||||||
|
// 在接纳资源的同步事务内创建 image 元素,使 binding 和内容一起发布。
|
||||||
const adopt=id=>engine.adoptResource('resource',(handle,tx)=>tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}}}));
|
const adopt=id=>engine.adoptResource('resource',(handle,tx)=>tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}}}));
|
||||||
try{
|
try{
|
||||||
|
// 预写源项目共享字节和引用后释放源 leader,目标接纳必须从共享 depot 获取而不复制上传。
|
||||||
await storage.commit(leader,{image:image(0),depot:{...cut([['resource',['element:source']]]),writes:[{ref,bytes}]}});await leader.release();leader=undefined;await open();
|
await storage.commit(leader,{image:image(0),depot:{...cut([['resource',['element:source']]]),writes:[{ref,bytes}]}});await leader.release();leader=undefined;await open();
|
||||||
assert((await adopt('picture')).status==='applied','adoption did not publish');await engine.settle();assert(authorizations===1&&reads===0&&submits===1,'adoption bypassed authorization, downloaded warm bytes or submitted twice');assert(engine.getSnapshot().runtime.resources.size===0&&engine.getSnapshot().runtime.assetBindings.size===1,'adoption invented upload metadata');
|
assert((await adopt('picture')).status==='applied','adoption did not publish');await engine.settle();assert(authorizations===1&&reads===0&&submits===1,'adoption bypassed authorization, downloaded warm bytes or submitted twice');assert(engine.getSnapshot().runtime.resources.size===0&&engine.getSnapshot().runtime.assetBindings.size===1,'adoption invented upload metadata');
|
||||||
|
// 关闭整个 runtime 再创建新 storage,验证目标 binding 与原始字节已经持久化。
|
||||||
await runtime.close();runtime=undefined;storage=createBrowserStorage({name});await open();
|
await runtime.close();runtime=undefined;storage=createBrowserStorage({name});await open();
|
||||||
const display=await engine.displayResource('resource');assert(display.url.startsWith('blob:')&&await(await fetch(display.url)).text()==='shared original','reopening lost durable binding or shared bytes');display.release();
|
const display=await engine.displayResource('resource');assert(display.url.startsWith('blob:')&&await(await fetch(display.url)).text()==='shared original','reopening lost durable binding or shared bytes');display.release();
|
||||||
|
// 已有缓存也不能绕过撤权;失败必须维持同一状态根,随后重新允许以继续历史检查。
|
||||||
allowed=false;const before=engine.getSnapshot();try{await adopt('denied');throw Error('revoked cache adoption succeeded');}catch(error){assert(error.message==='denied','unexpected authorization failure');}assert(engine.getSnapshot()===before,'denied authorization changed Root');allowed=true;
|
allowed=false;const before=engine.getSnapshot();try{await adopt('denied');throw Error('revoked cache adoption succeeded');}catch(error){assert(error.message==='denied','unexpected authorization failure');}assert(engine.getSnapshot()===before,'denied authorization changed Root');allowed=true;
|
||||||
assert(engine.editor.history.undo().status==='applied','adopt undo failed');await engine.settle();assert(engine.editor.history.redo().status==='applied','adopt redo failed');await engine.settle();assert(engine.getSnapshot().document.elements.get('picture').incarnation===2,'redo did not create a fresh incarnation');
|
assert(engine.editor.history.undo().status==='applied','adopt undo failed');await engine.settle();assert(engine.editor.history.redo().status==='applied','adopt redo failed');await engine.settle();assert(engine.getSnapshot().document.elements.get('picture').incarnation===2,'redo did not create a fresh incarnation');
|
||||||
// Close every editor. GC must use target's durable manifest rather than an open engine map.
|
// Close every editor. GC must use target's durable manifest rather than an open engine map.
|
||||||
await engine.close();leader=await storage.claim(scope);await storage.commit(leader,{image:image(1),depot:cut([])});const collected=await storage.assetDepot.collect(leader,{authority,now:100,retentionMs:0,limit:128});assert(collected.complete&&collected.pinnedIds.includes('resource')&&!collected.purgedIds.length,'closed destination pins did not protect shared bytes');
|
await engine.close();leader=await storage.claim(scope);await storage.commit(leader,{image:image(1),depot:cut([])});const collected=await storage.assetDepot.collect(leader,{authority,now:100,retentionMs:0,limit:128});assert(collected.complete&&collected.pinnedIds.includes('resource')&&!collected.purgedIds.length,'closed destination pins did not protect shared bytes');
|
||||||
|
// 直接扫描已关闭目标的项目文件表,确认共享资源没有被复制成目标项目本地文件。
|
||||||
const targetLeader=await storage.claim({...scope,projectId:'target'});try{const page=await storage.scanFiles(targetLeader,{expectedRootRevision:(await storage.recover(targetLeader)).rootRevision,limit:128});assert(page.files.length===0,'destination duplicated shared bytes into project files');}finally{await targetLeader.release();}
|
const targetLeader=await storage.claim({...scope,projectId:'target'});try{const page=await storage.scanFiles(targetLeader,{expectedRootRevision:(await storage.recover(targetLeader)).rootRevision,limit:128});assert(page.files.length===0,'destination duplicated shared bytes into project files');}finally{await targetLeader.release();}
|
||||||
await leader.release();leader=undefined;await runtime.close();runtime=undefined;
|
await leader.release();leader=undefined;await runtime.close();runtime=undefined;
|
||||||
}finally{await leader?.release();await runtime?.close();storage.close();indexedDB.deleteDatabase(name);}
|
}finally{await leader?.release();await runtime?.close();storage.close();indexedDB.deleteDatabase(name);}
|
||||||
|
|||||||
@@ -1,22 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 已有资源接纳(adoption)与文档图片导入的引擎测试。复用原始字节仍需重新授权,不生成多余上传任务。
|
||||||
|
* 绑定、文档、history、ledger 和导入进度通过一致状态根发布,资源字节及引用通过相应持久化 cut 保存。
|
||||||
|
* 内存端口可控制授权、配额和提交时机;真实 IndexedDB 的事务/回收竞态由 browser-cases 另测。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createRegistry,builtinComponents,createDocument,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createRegistry,builtinComponents,createDocument,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||||
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.ts';
|
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.ts';
|
||||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||||
|
// 共享内置定义及账户/工作区作用域;authority 标识原始资源的授权来源。
|
||||||
const registry=createRegistry(builtinComponents()),authority='https://source.test/w/assets',accountId='account',workspaceId='workspace';
|
const registry=createRegistry(builtinComponents()),authority='https://source.test/w/assets',accountId='account',workspaceId='workspace';
|
||||||
|
// 计算真实 Blob 的 SHA-256;此处 MIME 为测试元数据,不试图解码文本字节为图片像素。
|
||||||
const bytes=new Blob(['original bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
|
const bytes=new Blob(['original bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
|
||||||
|
// 不可变资源引用同时包含 authority、digest 和服务端资源元数据,不能只以 URL 代表身份。
|
||||||
const ref={version:1,authority,digest,asset:{id:'shared',url:`${authority}/shared`,bytes:bytes.size,width:2,height:1,mime_type:'image/png'}};
|
const ref={version:1,authority,digest,asset:{id:'shared',url:`${authority}/shared`,bytes:bytes.size,width:2,height:1,mime_type:'image/png'}};
|
||||||
|
// 使用接纳句柄创建 image 元素;assetId 保存资源身份,src 使用经授权的来源。
|
||||||
const node=(handle,id='picture',owner='canvas')=>({id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}});
|
const node=(handle,id='picture',owner='canvas')=>({id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}});
|
||||||
|
// 为各项目维护独立文档/回执/leader,同时在同账户工作区内共享 depot 字节;开关可阻塞授权或模拟配额失败。
|
||||||
function fixture(){
|
function fixture(){
|
||||||
const sourceFiles=new Map(),roots=new Map(),cache=new Map(),leaders=new Map(),servers=new Map(),calls=[],batches=[],errors=[];let now=0,next=0,allowed=true,gate,fail=false,closed=false;
|
const sourceFiles=new Map(),roots=new Map(),cache=new Map(),leaders=new Map(),servers=new Map(),calls=[],batches=[],errors=[];let now=0,next=0,allowed=true,gate,fail=false,closed=false;
|
||||||
|
// 共享缓存键含账户、工作区及 authority+assetId;项目 ID 不进入键,便于跨项目复用。
|
||||||
const key=(leader,id)=>JSON.stringify([leader.scope.accountId,leader.scope.workspaceId,id]);
|
const key=(leader,id)=>JSON.stringify([leader.scope.accountId,leader.scope.workspaceId,id]);
|
||||||
const storage={assetDepot:{async read(leader,auth,id){assert.equal(leader.active,true);return cache.get(key(leader,auth+id));},async collect(){return{complete:false,blockedProjects:['fixture']};}},async claim(scope){assert.equal(leaders.has(scope.projectId),false);const leader={scope,fence:1,active:true,async release(){leader.active=false;leaders.delete(scope.projectId);}};leaders.set(scope.projectId,leader);return leader;},async recover(leader){return structuredClone(roots.get(leader.scope.projectId)??null);},async readFile(_leader,id){return sourceFiles.get(id);},async commit(leader,batch){if(fail)throw Error('quota');assert.equal(leader.active,true);for(const file of batch.files??[])sourceFiles.set(file.id,file.bytes);const root=structuredClone(batch.image);for(const write of batch.depot?.writes??[]){const k=key(leader,write.ref.authority+write.ref.asset.id),previous=cache.get(k);cache.set(k,{ref:write.ref,bytes:write.bytes??previous?.bytes});}roots.set(leader.scope.projectId,root);batches.push(batch);return{durableRootRevision:root.rootRevision};},close(){closed=true;}};
|
// 最小存储端口:校验活动 leader,保存文件、image 及 depot writes;cache collect 故意不报告完整扫描。
|
||||||
const assets={authority:{scope:authority,async resolve(id,signal){calls.push('authorize');if(gate)await gate;if(!allowed)throw Error('denied');signal.throwIfAborted();return{...ref,asset:{...ref.asset,id,url:`${authority}/${id}`}};},async read(){calls.push('read');return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/remote/${id}`,async upload(){throw Error('adoption uploaded bytes');},async query(){throw Error('adoption queried upload');},accept(){throw Error('adoption accepted upload');}};
|
const storage={assetDepot:{
|
||||||
|
// depot 读取只接受活动 leader,并按账户/工作区/authority/资源身份查询共享缓存。
|
||||||
|
async read(leader,auth,id){assert.equal(leader.active,true);return cache.get(key(leader,auth+id));},
|
||||||
|
// 此内存 fixture 不证明完整 GC 覆盖,明确返回 blockedProjects 以避免假装可删除。
|
||||||
|
async collect(){return{complete:false,blockedProjects:['fixture']};}},
|
||||||
|
// 每个项目只允许一个 leader;release 将其标记失效并从占用表删除。
|
||||||
|
async claim(scope){assert.equal(leaders.has(scope.projectId),false);const leader={scope,fence:1,active:true,async release(){leader.active=false;leaders.delete(scope.projectId);}};leaders.set(scope.projectId,leader);return leader;},
|
||||||
|
// 从项目 roots 表克隆上次 image,缺失时返回 null。
|
||||||
|
async recover(leader){return structuredClone(roots.get(leader.scope.projectId)??null);},
|
||||||
|
// 测试用项目源文件表;与 depot 的跨项目字节缓存分开。
|
||||||
|
async readFile(_leader,id){return sourceFiles.get(id);},
|
||||||
|
// 配额故障在任何写之前抛出;正常写入项目文件、共享字节及克隆 image,并记录提交批次供断言。
|
||||||
|
async commit(leader,batch){if(fail)throw Error('quota');assert.equal(leader.active,true);for(const file of batch.files??[])sourceFiles.set(file.id,file.bytes);const root=structuredClone(batch.image);
|
||||||
|
// 只有 ref 更新而未提供新字节时沿用旧字节;真实事务回滚能力由浏览器用例覆盖。
|
||||||
|
for(const write of batch.depot?.writes??[]){const k=key(leader,write.ref.authority+write.ref.asset.id),previous=cache.get(k);cache.set(k,{ref:write.ref,bytes:write.bytes??previous?.bytes});}roots.set(leader.scope.projectId,root);batches.push(batch);return{durableRootRevision:root.rootRevision};},
|
||||||
|
// 记录 storage 关闭,供 WorkspaceRuntime 关闭顺序用例检查。
|
||||||
|
close(){closed=true;}};
|
||||||
|
// 授权必须每次执行,字节读取可由共享缓存避免;上传相关方法一旦被误调用就报错。
|
||||||
|
const assets={authority:{scope:authority,
|
||||||
|
// 先记录授权尝试并等待可控 gate,再检查权限和 AbortSignal;返回当前资源 ID 对应的规范引用。
|
||||||
|
async resolve(id,signal){calls.push('authorize');if(gate)await gate;if(!allowed)throw Error('denied');signal.throwIfAborted();return{...ref,asset:{...ref.asset,id,url:`${authority}/${id}`}};},
|
||||||
|
// 原始字节仅在缓存未命中时读取,计数用于检测重复下载。
|
||||||
|
async read(){calls.push('read');return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/remote/${id}`,
|
||||||
|
// 接纳既有资源不应创建上传;此断言端口一旦被调用就使测试失败。
|
||||||
|
async upload(){throw Error('adoption uploaded bytes');},
|
||||||
|
// 接纳也不应查询上传回执,否则说明错误进入上传状态机。
|
||||||
|
async query(){throw Error('adoption queried upload');},
|
||||||
|
// 接纳使用 authority 的资源证明,不能把它误当上传 accept 回执处理。
|
||||||
|
accept(){throw Error('adoption accepted upload');}};
|
||||||
|
// 每个项目的假服务各自保存文档和递增版本;提交记录 canonical 回执,供本地成功接纳使用。
|
||||||
function adapters(projectId){if(!servers.has(projectId))servers.set(projectId,{document:createDocument(projectId,[{id:'canvas',parent:null}],[]),version:1,receipts:new Map()});const server=servers.get(projectId);return{assets,persistence:{compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){calls.push('submit');const intent=JSON.parse(request.body).intent;server.document=applyChanges(server.document,intent.changes);server.receipts.set(request.id,{document:server.document,versions:new Map([['canvas',++server.version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>server.receipts.get(request.id)}};}
|
function adapters(projectId){if(!servers.has(projectId))servers.set(projectId,{document:createDocument(projectId,[{id:'canvas',parent:null}],[]),version:1,receipts:new Map()});const server=servers.get(projectId);return{assets,persistence:{compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){calls.push('submit');const intent=JSON.parse(request.body).intent;server.document=applyChanges(server.document,intent.changes);server.receipts.set(request.id,{document:server.document,versions:new Map([['canvas',++server.version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>server.receipts.get(request.id)}};}
|
||||||
|
// 暴露时间、授权、持久化和挂起开关;open 可复用上次 image,但不复用旧引擎实例。
|
||||||
return{advance(){now+=10000;},storage,roots,cache,errors,batches,calls,adapters,servers,get closed(){return closed;},deny(){allowed=false;},gate(value){gate=value;},fail(value){fail=value;},open(projectId='p',extra={}){return openProjectEngine({scope:{accountId,workspaceId,projectId},registry,document:createDocument(projectId,[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage,...adapters(projectId),clock:{now:()=>now,setTimer:()=>0,clearTimer(){}},createId:()=>`id-${++next}`,onError:e=>errors.push(e),...extra});}};
|
return{advance(){now+=10000;},storage,roots,cache,errors,batches,calls,adapters,servers,get closed(){return closed;},deny(){allowed=false;},gate(value){gate=value;},fail(value){fail=value;},open(projectId='p',extra={}){return openProjectEngine({scope:{accountId,workspaceId,projectId},registry,document:createDocument(projectId,[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage,...adapters(projectId),clock:{now:()=>now,setTimer:()=>0,clearTimer(){}},createId:()=>`id-${++next}`,onError:e=>errors.push(e),...extra});}};
|
||||||
}
|
}
|
||||||
|
// 把接纳和创建图片放在同一个同步事务内,确保不会只留下孤立 binding。
|
||||||
const adopt=(engine,id='picture')=>engine.adoptResource('shared',(handle,tx)=>tx.dispatch({type:'element.create',element:node(handle,id)}));
|
const adopt=(engine,id='picture')=>engine.adoptResource('shared',(handle,tx)=>tx.dispatch({type:'element.create',element:node(handle,id)}));
|
||||||
|
// 一次接纳同时发布绑定及历史/账本,保存共享字节而不生成上传;重开可显示原字节,undo/redo 递增元素身份。
|
||||||
test('adoption atomically publishes binding/history/ledger and reopens shared bytes without an upload',async()=>{
|
test('adoption atomically publishes binding/history/ledger and reopens shared bytes without an upload',async()=>{
|
||||||
const f=fixture();let engine=await f.open();try{
|
const f=fixture();let engine=await f.open();try{
|
||||||
assert.equal((await adopt(engine)).status,'applied');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings.get('shared').digest,digest);await engine.settle();
|
assert.equal((await adopt(engine)).status,'applied');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings.get('shared').digest,digest);await engine.settle();
|
||||||
@@ -25,12 +68,14 @@ test('adoption atomically publishes binding/history/ledger and reopens shared by
|
|||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(f.calls.filter(v=>v==='read').length,1);assert.equal(f.errors.length,0);
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(f.calls.filter(v=>v==='read').length,1);assert.equal(f.errors.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 第二个项目复用共享字节但再次授权;权限撤销后即使缓存仍在,也不能修改状态根。
|
||||||
test('a second project uses shared bytes but must obtain fresh permission before creating a binding',async()=>{
|
test('a second project uses shared bytes but must obtain fresh permission before creating a binding',async()=>{
|
||||||
const f=fixture(),p=await f.open('p');await adopt(p);await p.settle();await p.close();let q=await f.open('q');try{
|
const f=fixture(),p=await f.open('p');await adopt(p);await p.settle();await p.close();let q=await f.open('q');try{
|
||||||
assert.equal((await adopt(q)).status,'applied');await q.settle();assert.equal(f.calls.filter(v=>v==='authorize').length,2);assert.equal(f.calls.filter(v=>v==='read').length,1);await q.close();q=await f.open('q');const display=await q.displayResource('shared');assert.equal(await(await fetch(display.url)).text(),'original bytes');display.release();
|
assert.equal((await adopt(q)).status,'applied');await q.settle();assert.equal(f.calls.filter(v=>v==='authorize').length,2);assert.equal(f.calls.filter(v=>v==='read').length,1);await q.close();q=await f.open('q');const display=await q.displayResource('shared');assert.equal(await(await fetch(display.url)).text(),'original bytes');display.release();
|
||||||
f.deny();const before=q.getSnapshot();await assert.rejects(adopt(q,'denied'),/denied/);assert.strictEqual(q.getSnapshot(),before);
|
f.deny();const before=q.getSnapshot();await assert.rejects(adopt(q,'denied'),/denied/);assert.strictEqual(q.getSnapshot(),before);
|
||||||
}finally{await q.close();}
|
}finally{await q.close();}
|
||||||
});
|
});
|
||||||
|
// 重复 ID 导致事务拒绝、异步事务或空事务都不能留下半份 binding/history,后续落盘也不应写缓存。
|
||||||
test('rejected and asynchronous transactions cannot leave a binding or history behind',async()=>{
|
test('rejected and asynchronous transactions cannot leave a binding or history behind',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{
|
const f=fixture(),engine=await f.open();try{
|
||||||
const before=engine.getSnapshot();const rejected=await engine.adoptResource('shared',(handle,tx)=>{tx.dispatch({type:'element.create',element:node(handle)});tx.dispatch({type:'element.create',element:node(handle)});});assert.equal(rejected.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
const before=engine.getSnapshot();const rejected=await engine.adoptResource('shared',(handle,tx)=>{tx.dispatch({type:'element.create',element:node(handle)});tx.dispatch({type:'element.create',element:node(handle)});});assert.equal(rejected.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
||||||
@@ -38,24 +83,29 @@ test('rejected and asynchronous transactions cannot leave a binding or history b
|
|||||||
assert.equal((await engine.adoptResource('shared',()=>{})).operationIds.length,0);assert.strictEqual(engine.getSnapshot(),before);await engine.settle();assert.equal(f.cache.size,0);
|
assert.equal((await engine.adoptResource('shared',()=>{})).operationIds.length,0);assert.strictEqual(engine.getSnapshot(),before);await engine.settle();assert.equal(f.cache.size,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// binding 只能由接纳路径管理;格式降级、操作/资源身份不符和伪造 digest 无法通过恢复,换 authority 也不能解释旧绑定。
|
||||||
test('binding mutation is owned and recovery rejects downgraded, mismatched or forged bindings',async()=>{
|
test('binding mutation is owned and recovery rejects downgraded, mismatched or forged bindings',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{await adopt(engine);await engine.settle();assert.equal(engine.editor.updateRuntime(previous=>({...previous,assetBindings:new Map()})).code,'asset_bindings_owned');
|
const f=fixture(),engine=await f.open();try{await adopt(engine);await engine.settle();assert.equal(engine.editor.updateRuntime(previous=>({...previous,assetBindings:new Map()})).code,'asset_bindings_owned');
|
||||||
for(const change of [image=>image.formatVersion=12,image=>image.payload.runtime.assetBindings[0][1].operationId='missing',image=>image.payload.runtime.assetBindings[0][1].asset.id='other',image=>image.payload.runtime.assetBindings[0][1].digest='bad']){const image=structuredClone(f.roots.get('p'));change(image);assert.throws(()=>decodeRecovery(image,registry));}
|
for(const change of [image=>image.formatVersion=12,image=>image.payload.runtime.assetBindings[0][1].operationId='missing',image=>image.payload.runtime.assetBindings[0][1].asset.id='other',image=>image.payload.runtime.assetBindings[0][1].digest='bad']){const image=structuredClone(f.roots.get('p'));change(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||||
await engine.close();await assert.rejects(f.open('p',{assets:{...f.adapters('p').assets,authority:{...f.adapters('p').assets.authority,scope:'other'}}}),{code:'asset_binding_interpreter_unavailable'});
|
await engine.close();await assert.rejects(f.open('p',{assets:{...f.adapters('p').assets,authority:{...f.adapters('p').assets.authority,scope:'other'}}}),{code:'asset_binding_interpreter_unavailable'});
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 配额失败保留本地可见图片,但阻止文档网络派发;修复后先使原子 cut 持久化,再允许发送一次文档请求。
|
||||||
test('quota failure preserves the visible adopted image and blocks document dispatch until its atomic cut is durable',async()=>{
|
test('quota failure preserves the visible adopted image and blocks document dispatch until its atomic cut is durable',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{await engine.settle();f.fail(true);assert.equal((await adopt(engine)).status,'applied');await assert.rejects(engine.settle(),/quota/);assert.equal(f.calls.includes('submit'),false);assert.equal(f.cache.size,0);const display=await engine.displayResource('shared');assert.equal(await(await fetch(display.url)).text(),'original bytes');display.release();f.fail(false);await engine.retry();await engine.settle();f.advance();await engine.retry();await engine.settle();assert.equal(f.calls.filter(v=>v==='submit').length,1);assert.equal(f.cache.size,1);}finally{f.fail(false);await engine.close();}
|
const f=fixture(),engine=await f.open();try{await engine.settle();f.fail(true);assert.equal((await adopt(engine)).status,'applied');await assert.rejects(engine.settle(),/quota/);assert.equal(f.calls.includes('submit'),false);assert.equal(f.cache.size,0);const display=await engine.displayResource('shared');assert.equal(await(await fetch(display.url)).text(),'original bytes');display.release();f.fail(false);await engine.retry();await engine.settle();f.advance();await engine.retry();await engine.settle();assert.equal(f.calls.filter(v=>v==='submit').length,1);assert.equal(f.cache.size,1);}finally{f.fail(false);await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 授权仍挂起时关闭引擎,迟到授权结果不能发布内容、绑定或共享缓存,关闭过程仍能完成。
|
||||||
test('scope retirement and close fence late adoption, retaining no half binding',async()=>{
|
test('scope retirement and close fence late adoption, retaining no half binding',async()=>{
|
||||||
const f=fixture();let done;f.gate(new Promise(resolve=>done=resolve));const engine=await f.open(),pending=adopt(engine);await Promise.resolve();await Promise.resolve();const closing=engine.close();done();await assert.rejects(pending);await closing;assert.equal(f.roots.get('p').payload.document.elements.length,0);assert.equal(f.cache.size,0);
|
const f=fixture();let done;f.gate(new Promise(resolve=>done=resolve));const engine=await f.open(),pending=adopt(engine);await Promise.resolve();await Promise.resolve();const closing=engine.close();done();await assert.rejects(pending);await closing;assert.equal(f.roots.get('p').payload.document.elements.length,0);assert.equal(f.cache.size,0);
|
||||||
});
|
});
|
||||||
|
// WorkspaceRuntime 为多个项目提供同一个 depot;关闭项目后再关闭共享资源和存储,不能冻结调用者 storage 对象。
|
||||||
test('WorkspaceRuntime owns one depot across projects and closes it after projects',async()=>{
|
test('WorkspaceRuntime owns one depot across projects and closes it after projects',async()=>{
|
||||||
const f=fixture(),runtime=createWorkspaceRuntime({accountId,workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:id=>f.adapters(id)});
|
const f=fixture(),runtime=createWorkspaceRuntime({accountId,workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:id=>f.adapters(id)});
|
||||||
assert.deepEqual(runtime.assetDepot.scope,{accountId,workspaceId});assert.equal(Object.isFrozen(f.storage),false);
|
assert.deepEqual(runtime.assetDepot.scope,{accountId,workspaceId});assert.equal(Object.isFrozen(f.storage),false);
|
||||||
const p=await runtime.open(createDocument('p',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(p);await p.settle();const q=await runtime.open(createDocument('q',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(q);await q.settle();assert.equal(runtime.assetDepot.getResources().size,1);assert.equal(f.calls.filter(v=>v==='read').length,1);await runtime.close();assert.equal(f.closed,true);assert.equal(runtime.assetDepot.getResources().size,0);
|
const p=await runtime.open(createDocument('p',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(p);await p.settle();const q=await runtime.open(createDocument('q',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(q);await q.settle();assert.equal(runtime.assetDepot.getResources().size,1);assert.equal(f.calls.filter(v=>v==='read').length,1);await runtime.close();assert.equal(f.closed,true);assert.equal(runtime.assetDepot.getResources().size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 旧的无引用状态根正在落盘时清除字节,再接纳新图片;新字节必须留到匹配的新 cut 持久化,不能被旧提交释放。
|
||||||
test('an older Root cannot release newly adopted bytes while its unpinned manifest is committing',async()=>{
|
test('an older Root cannot release newly adopted bytes while its unpinned manifest is committing',async()=>{
|
||||||
const f=fixture(),engine=await f.open('p',{historyLimit:1});let release,started;const commit=f.storage.commit;
|
const f=fixture(),engine=await f.open('p',{historyLimit:1});let release,started;const commit=f.storage.commit;
|
||||||
try{
|
try{
|
||||||
@@ -67,9 +117,15 @@ test('an older Root cannot release newly adopted bytes while its unpinned manife
|
|||||||
f.cache.clear();assert.equal((await adopt(engine,'fresh')).status,'applied');release();await engine.settle();assert.equal(await f.cache.values().next().value.bytes.text(),'original bytes');assert.equal(f.roots.get('p').payload.document.elements.some(element=>element.id==='fresh'),true);
|
f.cache.clear();assert.equal((await adopt(engine,'fresh')).status,'applied');release();await engine.settle();assert.equal(await f.cache.values().next().value.bytes.text(),'original bytes');assert.equal(f.roots.get('p').payload.document.elements.some(element=>element.id==='fresh'),true);
|
||||||
}finally{release?.();f.storage.commit=commit;await engine.close();}
|
}finally{release?.();f.storage.commit=commit;await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 生成含图片引用的导入文件;其 src 故意不可信,导入应改用 authority 的经授权原始 URL。
|
||||||
const exportedImages=(ids=['shared'])=>new Blob([JSON.stringify({documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:ids.map((id,index)=>node({id,src:'https://untrusted.test/ignored'},'imported-'+index,'source')),tombstones:{}})]);
|
const exportedImages=(ids=['shared'])=>new Blob([JSON.stringify({documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:ids.map((id,index)=>node({id,src:'https://untrusted.test/ignored'},'imported-'+index,'source')),tombstones:{}})]);
|
||||||
|
// 文档导入授权原字节,并以同一状态根发布绑定、内容和 committedSourceKeys;不上传,重开后仍可显示。
|
||||||
test('document image import authorizes original bytes and atomically publishes binding, content and progress without upload, then reopens',async()=>{const f=fixture();let engine=await f.open();try{const observed=[],stop=engine.editor.subscribe((_,root)=>{if(root.document.elements.size){const job=[...root.runtime.imports.values()][0];observed.push(root);assert.ok(root.runtime.assetBindings.get('shared'));assert.equal(job.committedSourceKeys.length,root.document.elements.size);}}),id=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();stop();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.ok(observed.length);const image=[...engine.getSnapshot().document.elements.values()][0];assert.equal(image.data.src,ref.asset.url);assert.equal(image.data.assetId,'shared');assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(f.calls.filter(x=>x==='authorize').length,1);assert.equal(f.roots.get('p').formatVersion,17);decodeRecovery(f.roots.get('p'),registry);await engine.close();engine=await f.open();const handle=await engine.displayResource('shared');assert.equal(await(await fetch(handle.url)).text(),'original bytes');handle.release();assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}});
|
test('document image import authorizes original bytes and atomically publishes binding, content and progress without upload, then reopens',async()=>{const f=fixture();let engine=await f.open();try{const observed=[],stop=engine.editor.subscribe((_,root)=>{if(root.document.elements.size){const job=[...root.runtime.imports.values()][0];observed.push(root);assert.ok(root.runtime.assetBindings.get('shared'));assert.equal(job.committedSourceKeys.length,root.document.elements.size);}}),id=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();stop();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.ok(observed.length);const image=[...engine.getSnapshot().document.elements.values()][0];assert.equal(image.data.src,ref.asset.url);assert.equal(image.data.assetId,'shared');assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(f.calls.filter(x=>x==='authorize').length,1);assert.equal(f.roots.get('p').formatVersion,17);decodeRecovery(f.roots.get('p'),registry);await engine.close();engine=await f.open();const handle=await engine.displayResource('shared');assert.equal(await(await fetch(handle.url)).text(),'original bytes');handle.release();assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}});
|
||||||
|
// 取消挂起的授权后迟到结果不能发布;后续权限拒绝的导入保留 partial 失败状态,不能生成 binding。
|
||||||
test('document image authorization cannot publish after cancel and denied imports retain explicit failure',async()=>{const f=fixture(),engine=await f.open();let release;try{f.gate(new Promise(resolve=>release=resolve));const id=engine.imports.startDocument(exportedImages(),'canvas');for(let i=0;i<100&&!f.calls.includes('authorize');i++)await new Promise(resolve=>setTimeout(resolve,1));assert.ok(f.calls.includes('authorize'));assert.equal(engine.getSnapshot().document.elements.size,0);await engine.imports.cancel(id);release();f.gate();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);f.deny();const denied=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(denied).status,'partial');assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);}finally{release?.();await engine.close();}});
|
test('document image authorization cannot publish after cancel and denied imports retain explicit failure',async()=>{const f=fixture(),engine=await f.open();let release;try{f.gate(new Promise(resolve=>release=resolve));const id=engine.imports.startDocument(exportedImages(),'canvas');for(let i=0;i<100&&!f.calls.includes('authorize');i++)await new Promise(resolve=>setTimeout(resolve,1));assert.ok(f.calls.includes('authorize'));assert.equal(engine.getSnapshot().document.elements.size,0);await engine.imports.cancel(id);release();f.gate();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);f.deny();const denied=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(denied).status,'partial');assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);}finally{release?.();await engine.close();}});
|
||||||
|
// 同一文档批次接纳两个资源时,绑定和元素数量一致,完成记录可由恢复 codec 解码。
|
||||||
test('one document batch can adopt multiple immutable resources with one consistent Root',async()=>{const f=fixture(),engine=await f.open();try{const id=engine.imports.startDocument(exportedImages(['shared','second']),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().runtime.assetBindings.size,2);assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(f.errors.length,0);decodeRecovery(f.roots.get('p'),registry);}finally{await engine.close();}});
|
test('one document batch can adopt multiple immutable resources with one consistent Root',async()=>{const f=fixture(),engine=await f.open();try{const id=engine.imports.startDocument(exportedImages(['shared','second']),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().runtime.assetBindings.size,2);assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(f.errors.length,0);decodeRecovery(f.roots.get('p'),registry);}finally{await engine.close();}});
|
||||||
|
// 非法图片几何使导入事务拒绝,即使已经授权也不能留下 binding、内容、缓存字节或 depot write。
|
||||||
test('rejected document image transaction never leaves an adopted binding or durable byte write',async()=>{const f=fixture(),engine=await f.open();try{const input=JSON.parse(await exportedImages().text());input.elements[0].geometry.width=-1;const id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);assert.equal(f.cache.size,0);assert.ok(!f.batches.some(batch=>batch.depot?.writes.length));}finally{await engine.close();}});
|
test('rejected document image transaction never leaves an adopted binding or durable byte write',async()=>{const f=fixture(),engine=await f.open();try{const input=JSON.parse(await exportedImages().text());input.elements[0].geometry.width=-1;const id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);assert.equal(f.cache.size,0);assert.ok(!f.batches.some(batch=>batch.depot?.writes.length));}finally{await engine.close();}});
|
||||||
|
// 第一次导入已缓存字节,第二份文档仍重新检查权限;拒绝后只保留第一份已发布内容。
|
||||||
test('resource permission is checked again for another document even after its bytes are cached',async()=>{const f=fixture(),engine=await f.open();try{const first=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(first).status,'completed');f.deny();const second=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(second).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.calls.filter(x=>x==='authorize').length>=2,true);}finally{await engine.close();}});
|
test('resource permission is checked again for another document even after its bytes are cached',async()=>{const f=fixture(),engine=await f.open();try{const first=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(first).status,'completed');f.deny();const second=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(second).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.calls.filter(x=>x==='authorize').length>=2,true);}finally{await engine.close();}});
|
||||||
|
|||||||
@@ -1,33 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 共享资源 depot 的浏览器存储集成场景,使用真实 IndexedDB 事务及原始 object store 计数。
|
||||||
|
* 覆盖跨项目引用、作用域隔离、回收扫描完整性、摘要去重、事务回滚,以及实际引擎上传后的字节提升。
|
||||||
|
*/
|
||||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
||||||
|
// 注入断言并创建独立数据库;所有领取的 leader 统一记录,最后释放并删库。
|
||||||
export async function assetDepotStorageCase(assert){
|
export async function assetDepotStorageCase(assert){
|
||||||
const name=`depot-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),scope={accountId:'a',workspaceId:'w',projectId:'p'},leaders=[];
|
const name=`depot-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),scope={accountId:'a',workspaceId:'w',projectId:'p'},leaders=[];
|
||||||
|
// 生成最小 image 并计算原始测试字节摘要;资源身份元数据与摘要共享字节分开保存。
|
||||||
const image=n=>({formatVersion:1,rootRevision:n,localRevision:n,payload:{n}}),bytes=new Blob(['shared original'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),authority='https://source.test/w/assets';
|
const image=n=>({formatVersion:1,rootRevision:n,localRevision:n,payload:{n}}),bytes=new Blob(['shared original'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),authority='https://source.test/w/assets';
|
||||||
|
// 构造不同资源 ID 的相同字节引用,以及完整/不完整 manifest 和固定宽限期的回收调用。
|
||||||
const ref=id=>({version:1,authority,digest,asset:{id,url:`https://source.test/w/assets/${id}`,width:2,height:1,bytes:bytes.size,mime_type:'image/png'}}),cut=(pins=[],writes=[],complete=true)=>({version:1,authority,pins,writes,complete}),collect=(leader,now,extra={})=>storage.assetDepot.collect(leader,{authority,now,retentionMs:10,limit:128,...extra});
|
const ref=id=>({version:1,authority,digest,asset:{id,url:`https://source.test/w/assets/${id}`,width:2,height:1,bytes:bytes.size,mime_type:'image/png'}}),cut=(pins=[],writes=[],complete=true)=>({version:1,authority,pins,writes,complete}),collect=(leader,now,extra={})=>storage.assetDepot.collect(leader,{authority,now,retentionMs:10,limit:128,...extra});
|
||||||
|
// 要求异步调用以指定错误码失败;正常返回或错误码不符都使测试失败。
|
||||||
const rejects=async(fn,code)=>{try{await fn();}catch(e){assert(e.code===code,`expected ${code}, got ${e.code}`);return;}throw Error(`expected ${code}`);};
|
const rejects=async(fn,code)=>{try{await fn();}catch(e){assert(e.code===code,`expected ${code}, got ${e.code}`);return;}throw Error(`expected ${code}`);};
|
||||||
|
// 按项目领取 leader 并记录到清理列表,让关闭项目的 manifest 仍可参与共享回收。
|
||||||
const open=async(projectId)=>{const leader=await storage.claim({...scope,projectId});leaders.push(leader);return leader;};
|
const open=async(projectId)=>{const leader=await storage.claim({...scope,projectId});leaders.push(leader);return leader;};
|
||||||
try{
|
try{
|
||||||
const p=await open('p'),q=await open('q');
|
const p=await open('p'),q=await open('q');
|
||||||
await storage.commit(p,{image:image(0),depot:cut([['one',['element:p']]], [{ref:ref('one'),bytes},{ref:ref('two'),bytes}])});
|
await storage.commit(p,{image:image(0),depot:cut([['one',['element:p']]], [{ref:ref('one'),bytes},{ref:ref('two'),bytes}])});
|
||||||
await storage.commit(q,{image:image(0),depot:cut([['two',['history:q']]])});
|
await storage.commit(q,{image:image(0),depot:cut([['two',['history:q']]])});
|
||||||
|
// 额外打开原始 IDB 连接用于检查物理行数,避免只凭公开读取接口推断去重。
|
||||||
const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||||
|
// 直接统计 asset-bytes 的物理摘要记录数,区分资源身份数与实际存储的字节副本数。
|
||||||
const count=()=>new Promise((resolve,reject)=>{const tx=database.transaction('asset-bytes'),request=tx.objectStore('asset-bytes').count();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
const count=()=>new Promise((resolve,reject)=>{const tx=database.transaction('asset-bytes'),request=tx.objectStore('asset-bytes').count();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||||
assert(await count()===1,'same digest was physically duplicated across identities');
|
assert(await count()===1,'same digest was physically duplicated across identities');
|
||||||
await p.release();assert((await collect(q,0)).pinnedIds.length===2,'closed project pins were lost');
|
await p.release();assert((await collect(q,0)).pinnedIds.length===2,'closed project pins were lost');
|
||||||
assert(await(await storage.assetDepot.read(q,authority,'one')).bytes.text()==='shared original','destination could not read shared bytes');
|
assert(await(await storage.assetDepot.read(q,authority,'one')).bytes.text()==='shared original','destination could not read shared bytes');
|
||||||
|
// 分别换账户、工作区及 authority,确认共享缓存只在相同授权作用域内可读。
|
||||||
const otherAccount=await storage.claim({...scope,accountId:'other'});leaders.push(otherAccount);assert(await storage.assetDepot.read(otherAccount,authority,'one')===undefined,'account cache leak');
|
const otherAccount=await storage.claim({...scope,accountId:'other'});leaders.push(otherAccount);assert(await storage.assetDepot.read(otherAccount,authority,'one')===undefined,'account cache leak');
|
||||||
const otherWorkspace=await storage.claim({...scope,workspaceId:'other'});leaders.push(otherWorkspace);assert(await storage.assetDepot.read(otherWorkspace,authority,'one')===undefined,'workspace cache leak');
|
const otherWorkspace=await storage.claim({...scope,workspaceId:'other'});leaders.push(otherWorkspace);assert(await storage.assetDepot.read(otherWorkspace,authority,'one')===undefined,'workspace cache leak');
|
||||||
assert(await storage.assetDepot.read(q,'https://other.test','one')===undefined,'authority cache leak');
|
assert(await storage.assetDepot.read(q,'https://other.test','one')===undefined,'authority cache leak');
|
||||||
|
// 缺少索引、不完整或落后于最新 Root 的 manifest 都会阻止安全删除;随后补齐才能继续回收。
|
||||||
const stale=await open('stale');await storage.commit(stale,{image:image(0)});assert((await collect(q,100)).blockedProjects.includes('stale'),'unindexed project allowed deletion');
|
const stale=await open('stale');await storage.commit(stale,{image:image(0)});assert((await collect(q,100)).blockedProjects.includes('stale'),'unindexed project allowed deletion');
|
||||||
await storage.commit(stale,{image:image(0),depot:cut([],[],false)});assert(!(await collect(q,100)).complete,'opaque project allowed deletion');
|
await storage.commit(stale,{image:image(0),depot:cut([],[],false)});assert(!(await collect(q,100)).complete,'opaque project allowed deletion');
|
||||||
await storage.commit(stale,{image:image(0),depot:cut()});await storage.commit(stale,{image:image(1)});assert(!(await collect(q,100)).complete,'stale manifest allowed deletion');
|
await storage.commit(stale,{image:image(0),depot:cut()});await storage.commit(stale,{image:image(1)});assert(!(await collect(q,100)).complete,'stale manifest allowed deletion');
|
||||||
await storage.commit(stale,{image:image(1),depot:cut()});
|
await storage.commit(stale,{image:image(1),depot:cut()});
|
||||||
|
// 重新打开源项目清除引用,再经标记和宽限期回收其中一个身份;其他同摘要资源仍持有物理字节。
|
||||||
const reopened=await open('p');await storage.commit(reopened,{image:image(1),depot:cut()});
|
const reopened=await open('p');await storage.commit(reopened,{image:image(1),depot:cut()});
|
||||||
assert((await collect(q,100)).markedIds.includes('one'),'unreferenced resource was not marked');
|
assert((await collect(q,100)).markedIds.includes('one'),'unreferenced resource was not marked');
|
||||||
assert((await collect(q,110)).purgedIds.includes('one')&&await count()===1,'purging one identity deleted another identity bytes');
|
assert((await collect(q,110)).purgedIds.includes('one')&&await count()===1,'purging one identity deleted another identity bytes');
|
||||||
await storage.commit(q,{image:image(1),depot:cut()});await collect(q,110);await collect(q,120);assert(await count()===0,'expired unpinned shared bytes retained');
|
await storage.commit(q,{image:image(1),depot:cut()});await collect(q,110);await collect(q,120);assert(await count()===0,'expired unpinned shared bytes retained');
|
||||||
|
// 清除最后引用后字节应消失,但不可变身份与 purgedAt 仍保留,便于校验后续补回的内容。
|
||||||
const purged=await storage.assetDepot.read(q,authority,'one');assert(purged.purgedAt===110&&!purged.bytes,'immutable identity lost or purged bytes exposed');
|
const purged=await storage.assetDepot.read(q,authority,'one');assert(purged.purgedAt===110&&!purged.bytes,'immutable identity lost or purged bytes exposed');
|
||||||
|
// 摘要/元数据冲突必须整批拒绝,不能推进 rootRevision 或写入部分共享字节。
|
||||||
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([], [{ref:{...ref('one'),digest:'0'.repeat(64)},bytes}])}),'shared_asset_bytes_changed');assert((await storage.recover(q)).rootRevision===1,'invalid bytes advanced Root');
|
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([], [{ref:{...ref('one'),digest:'0'.repeat(64)},bytes}])}),'shared_asset_bytes_changed');assert((await storage.recover(q)).rootRevision===1,'invalid bytes advanced Root');
|
||||||
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([['one',['element:q']]], [{ref:{...ref('one'),asset:{...ref('one').asset,width:3}},bytes}])}),'shared_asset_identity_conflict');assert(await count()===0&&(await storage.recover(q)).rootRevision===1,'identity conflict partially committed bytes or Root');
|
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([['one',['element:q']]], [{ref:{...ref('one'),asset:{...ref('one').asset,width:3}},bytes}])}),'shared_asset_identity_conflict');assert(await count()===0&&(await storage.recover(q)).rootRevision===1,'identity conflict partially committed bytes or Root');
|
||||||
// Force a later files-store conflict after the depot puts: the whole transaction must roll back.
|
// Force a later files-store conflict after the depot puts: the whole transaction must roll back.
|
||||||
@@ -37,15 +53,21 @@ export async function assetDepotStorageCase(assert){
|
|||||||
// A one-item sweep must provide a cursor and preserve each resource decision.
|
// A one-item sweep must provide a cursor and preserve each resource decision.
|
||||||
const first=await collect(q,130,{limit:1});assert(first.nextAfter==='one','bounded sweep cursor missing');assert((await collect(q,130,{after:first.nextAfter,limit:1})).nextAfter===undefined,'bounded sweep did not finish');
|
const first=await collect(q,130,{limit:1});assert(first.nextAfter==='one','bounded sweep cursor missing');assert((await collect(q,130,{after:first.nextAfter,limit:1})).nextAfter===undefined,'bounded sweep did not finish');
|
||||||
// Exercise the actual engine persistence entry point, rather than only hand-built batches.
|
// Exercise the actual engine persistence entry point, rather than only hand-built batches.
|
||||||
|
// 从手工 batch 检查切换到真实引擎:stageFiles、确认上传及关闭后,再检查物理文件行。
|
||||||
const projectId='engine-project',registry=createRegistry(builtinComponents());let document=createDocument(projectId,[{id:'canvas',parent:null}],[]),version=1,counter=0,resource,uploads=0;
|
const projectId='engine-project',registry=createRegistry(builtinComponents());let document=createDocument(projectId,[{id:'canvas',parent:null}],[]),version=1,counter=0,resource,uploads=0;
|
||||||
|
// 模拟上传回执及 authority;此场景不应触发已有资源接纳,上传返回原始字节的服务端身份。
|
||||||
const receipts=new Map(),assets={authority:{scope:authority,async resolve(){throw Error('unexpected adoption');},async read(){throw Error('unexpected hydration');}},sourceURL:id=>`https://source.test/w/assets/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,data){uploads++;const raw=JSON.parse(request.body);return{outcome:'applied',canonical:{id:raw.resource_id,url:assets.sourceURL(raw.resource_id),width:2,height:1,bytes:data.size,mime_type:data.type}};},async query(){return{outcome:'unresolved'};},accept:(_request,status)=>status.canonical};
|
const receipts=new Map(),assets={authority:{scope:authority,async resolve(){throw Error('unexpected adoption');},async read(){throw Error('unexpected hydration');}},sourceURL:id=>`https://source.test/w/assets/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,data){uploads++;const raw=JSON.parse(request.body);return{outcome:'applied',canonical:{id:raw.resource_id,url:assets.sourceURL(raw.resource_id),width:2,height:1,bytes:data.size,mime_type:data.type}};},async query(){return{outcome:'unresolved'};},accept:(_request,status)=>status.canonical};
|
||||||
|
// 文档提交保存新 canonical 基线;资源上传和文档确认在此使用内存服务,存储端仍是真 IDB。
|
||||||
const persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
const persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
||||||
const engine=await openProjectEngine({scope:{...scope,projectId},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,createId:()=>`engine-${++counter}`});
|
const engine=await openProjectEngine({scope:{...scope,projectId},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,createId:()=>`engine-${++counter}`});
|
||||||
try{
|
try{
|
||||||
|
// 创建图片与上传任务,再有限轮询 stored 状态,避免在异步提升字节前检查存储。
|
||||||
const staged=engine.stageFiles([{name:'source.png',bytes}],([handle],tx)=>{resource=handle.id;tx.dispatch({type:'element.create',element:{id:'picture',incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:'picture',text:'',assetId:handle.id,src:handle.src}}});});assert(staged.status!=='rejected','engine image staging failed');
|
const staged=engine.stageFiles([{name:'source.png',bytes}],([handle],tx)=>{resource=handle.id;tx.dispatch({type:'element.create',element:{id:'picture',incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:'picture',text:'',assetId:handle.id,src:handle.src}}});});assert(staged.status!=='rejected','engine image staging failed');
|
||||||
let ready=false;for(let i=0;i<200;i++){if(engine.getSnapshot().runtime.resources.get(resource)?.stored){ready=true;break;}await new Promise(resolve=>setTimeout(resolve,5));}assert(ready,'engine upload did not settle');
|
let ready=false;for(let i=0;i<200;i++){if(engine.getSnapshot().runtime.resources.get(resource)?.stored){ready=true;break;}await new Promise(resolve=>setTimeout(resolve,5));}assert(ready,'engine upload did not settle');
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
|
// 读取引擎项目的 files 行,要求上传确认后为 fileVersion=1 指针,而非保留第二份 Blob。
|
||||||
const physical=await new Promise((resolve,reject)=>{const request=database.transaction('files').objectStore('files').get([JSON.stringify([scope.accountId,scope.workspaceId,projectId]),resource]);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(physical?.fileVersion===1&&!(physical instanceof Blob),'actual engine upload retained duplicate project bytes');
|
const physical=await new Promise((resolve,reject)=>{const request=database.transaction('files').objectStore('files').get([JSON.stringify([scope.accountId,scope.workspaceId,projectId]),resource]);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(physical?.fileVersion===1&&!(physical instanceof Blob),'actual engine upload retained duplicate project bytes');
|
||||||
|
// 共享 depot 应能读到提升后的原字节;上传只发生一次,关闭引擎的持久化 pin 仍保护资源。
|
||||||
const promoted=await storage.assetDepot.read(q,authority,resource);assert(promoted?.bytes&&await promoted.bytes.text()==='shared original'&&uploads===1,'engine did not promote acknowledged upload bytes');
|
const promoted=await storage.assetDepot.read(q,authority,resource);assert(promoted?.bytes&&await promoted.bytes.text()==='shared original'&&uploads===1,'engine did not promote acknowledged upload bytes');
|
||||||
assert((await collect(q,200)).pinnedIds.includes(resource),'closed engine did not retain durable document/history pins');
|
assert((await collect(q,200)).pinnedIds.includes(resource),'closed engine did not retain durable document/history pins');
|
||||||
await q.release();await rejects(()=>collect(q,200),'storage_leader_lost');database.close();
|
await q.release();await rejects(()=>collect(q,200),'storage_leader_lost');database.close();
|
||||||
|
|||||||
@@ -1,39 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 共享资源 depot、原始二进制传输及 Foundry authority 的单元测试。缓存降低读取成本,不能代替授权或身份验证。
|
||||||
|
* 控制权限和生命周期可检查迟到结果隔离;字节预算按实际流内容验证,不依赖声明长度。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createAssetDepot} from '../../src/lib/canvas-runtime/asset-depot.ts';
|
import {createAssetDepot} from '../../src/lib/canvas-runtime/asset-depot.ts';
|
||||||
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.ts';
|
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.ts';
|
||||||
import {createFoundryAssets} from '../../src/lib/canvas-service/assets.ts';
|
import {createFoundryAssets} from '../../src/lib/canvas-service/assets.ts';
|
||||||
|
// 对真实 Blob 字节计算十六进制 SHA-256,用于检查缓存/服务引用与内容是否匹配。
|
||||||
const digest=async bytes=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
|
const digest=async bytes=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
|
||||||
|
// 准备可替换缓存、权限及身份状态的 depot;resolve/read 分别计数,区分重新授权和重新下载。
|
||||||
async function fixture(){
|
async function fixture(){
|
||||||
const bytes=new Blob(['original'],{type:'image/png'}),scope={accountId:'account',workspaceId:'workspace'},leader={scope:{...scope,projectId:'p'},active:true},ref={version:1,authority:'authority',digest:await digest(bytes),asset:{id:'asset',url:'https://source.test/asset',width:2,height:1,bytes:bytes.size,mime_type:'image/png'}};
|
const bytes=new Blob(['original'],{type:'image/png'}),scope={accountId:'account',workspaceId:'workspace'},leader={scope:{...scope,projectId:'p'},active:true},ref={version:1,authority:'authority',digest:await digest(bytes),asset:{id:'asset',url:'https://source.test/asset',width:2,height:1,bytes:bytes.size,mime_type:'image/png'}};
|
||||||
let cached,allowed=true,resolves=0,reads=0,current=true;
|
let cached,allowed=true,resolves=0,reads=0,current=true;
|
||||||
|
// port 仅提供可注入缓存;current 判断身份生命周期,digest 检查内容,collect 在此 fixture 中不做回收。
|
||||||
const depot=createAssetDepot({scope,port:{async read(){return cached;},async collect(){return{complete:true};}},current:()=>current,digest});
|
const depot=createAssetDepot({scope,port:{async read(){return cached;},async collect(){return{complete:true};}},current:()=>current,digest});
|
||||||
|
// 每次 resolve 都检查权限并返回引用,read 单独提供原始字节,便于断言热缓存仍授权。
|
||||||
const authority={scope:ref.authority,async resolve(){resolves++;if(!allowed)throw Error('denied');return ref;},async read(){reads++;return bytes;}};
|
const authority={scope:ref.authority,async resolve(){resolves++;if(!allowed)throw Error('denied');return ref;},async read(){reads++;return bytes;}};
|
||||||
return{depot,scope,leader,ref,bytes,authority,setCached:v=>cached=v,deny:()=>allowed=false,retire:()=>current=false,counts:()=>({resolves,reads})};
|
return{depot,scope,leader,ref,bytes,authority,setCached:v=>cached=v,deny:()=>allowed=false,retire:()=>current=false,counts:()=>({resolves,reads})};
|
||||||
}
|
}
|
||||||
|
// 首次接纳下载原字节,热缓存再次接纳只重新授权;撤权后不能以缓存绕过 resolve,读取次数保持不变。
|
||||||
test('new cross-project adoption reauthorizes even when immutable bytes are warm',async()=>{
|
test('new cross-project adoption reauthorizes even when immutable bytes are warm',async()=>{
|
||||||
const f=await fixture(),signal=new AbortController().signal;
|
const f=await fixture(),signal=new AbortController().signal;
|
||||||
assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);
|
assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);
|
||||||
f.setCached({ref:f.ref,bytes:f.bytes});assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);assert.deepEqual(f.counts(),{resolves:2,reads:1});
|
f.setCached({ref:f.ref,bytes:f.bytes});assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);assert.deepEqual(f.counts(),{resolves:2,reads:1});
|
||||||
f.deny();await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),/denied/);assert.deepEqual(f.counts(),{resolves:3,reads:1});f.depot.close();
|
f.deny();await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),/denied/);assert.deepEqual(f.counts(),{resolves:3,reads:1});f.depot.close();
|
||||||
});
|
});
|
||||||
|
// depot 捕获作用域而不受外部 scope 改写影响;缓存身份冲突、字节篡改及当前身份失效分别拒绝。
|
||||||
test('depot captures scope, validates cached identity and bytes, and rejects revoked lifecycle',async()=>{
|
test('depot captures scope, validates cached identity and bytes, and rejects revoked lifecycle',async()=>{
|
||||||
const f=await fixture(),signal=new AbortController().signal;f.scope.accountId='mutated';assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).ref.asset.id,'asset');
|
const f=await fixture(),signal=new AbortController().signal;f.scope.accountId='mutated';assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).ref.asset.id,'asset');
|
||||||
f.setCached({ref:{...f.ref,digest:'0'.repeat(64)},bytes:f.bytes});await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),{code:'shared_asset_identity_conflict'});
|
f.setCached({ref:{...f.ref,digest:'0'.repeat(64)},bytes:f.bytes});await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),{code:'shared_asset_identity_conflict'});
|
||||||
f.setCached({ref:f.ref,bytes:new Blob(['tampered'])});await assert.rejects(f.depot.read(f.leader,f.ref),{code:'shared_asset_bytes_changed'});
|
f.setCached({ref:f.ref,bytes:new Blob(['tampered'])});await assert.rejects(f.depot.read(f.leader,f.ref),{code:'shared_asset_bytes_changed'});
|
||||||
f.retire();await assert.rejects(f.depot.read(f.leader,f.ref),{code:'identity_changed'});f.depot.close();
|
f.retire();await assert.rejects(f.depot.read(f.leader,f.ref),{code:'identity_changed'});f.depot.close();
|
||||||
});
|
});
|
||||||
|
// authority 结果返回前关闭 depot,迟到结果应以 identity_changed 拒绝,不能写入元数据缓存。
|
||||||
test('late authority result after close never enters the metadata cache',async()=>{
|
test('late authority result after close never enters the metadata cache',async()=>{
|
||||||
const f=await fixture();let done,started;const ready=new Promise(resolve=>started=resolve),wait=new Promise(resolve=>done=resolve);
|
const f=await fixture();let done,started;const ready=new Promise(resolve=>started=resolve),wait=new Promise(resolve=>done=resolve);
|
||||||
const promise=f.depot.authorize(f.leader,{...f.authority,async resolve(){started();await wait;return f.ref;}},'asset',new AbortController().signal);await ready;f.depot.close();done();await assert.rejects(promise,{code:'identity_changed'});assert.equal(f.depot.getResources().size,0);
|
const promise=f.depot.authorize(f.leader,{...f.authority,async resolve(){started();await wait;return f.ref;}},'asset',new AbortController().signal);await ready;f.depot.close();done();await assert.rejects(promise,{code:'identity_changed'});assert.equal(f.depot.getResources().size,0);
|
||||||
});
|
});
|
||||||
|
// 二进制响应保持原字节和 MIME;实际流超过 20 MiB 时取消读取,读取途中身份变化同样拒绝。
|
||||||
test('binary asset transport preserves original bytes and enforces identity and actual byte budgets',async()=>{
|
test('binary asset transport preserves original bytes and enforces identity and actual byte budgets',async()=>{
|
||||||
const accountId='11111111-1111-4111-8111-111111111111';let current=true,cancelled=false;
|
const accountId='11111111-1111-4111-8111-111111111111';let current=true,cancelled=false;
|
||||||
|
// 用指定 Response 替代 fetch,保留真实 transport 的流读取、大小限制及身份检查。
|
||||||
const transport=response=>createFoundryTransport({accountId,isCurrentIdentity:()=>current,fetch:async()=>response});
|
const transport=response=>createFoundryTransport({accountId,isCurrentIdentity:()=>current,fetch:async()=>response});
|
||||||
const result=await transport(new Response('original',{headers:{'content-type':'image/png'}}))({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'});assert.equal(await result.bytes.text(),'original');assert.equal(result.bytes.type,'image/png');
|
const result=await transport(new Response('original',{headers:{'content-type':'image/png'}}))({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'});assert.equal(await result.bytes.text(),'original');assert.equal(result.bytes.type,'image/png');
|
||||||
const oversized=new Response(new ReadableStream({start(c){c.enqueue(new Uint8Array(20*1024*1024+1));},cancel(){cancelled=true;}}));await assert.rejects(transport(oversized)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'asset_byte_budget_exceeded'});assert.equal(cancelled,true);
|
const oversized=new Response(new ReadableStream({start(c){c.enqueue(new Uint8Array(20*1024*1024+1));},cancel(){cancelled=true;}}));await assert.rejects(transport(oversized)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'asset_byte_budget_exceeded'});assert.equal(cancelled,true);
|
||||||
const identityResponse=new Response(new ReadableStream({pull(c){current=false;c.enqueue(new Uint8Array([1]));}}));await assert.rejects(transport(identityResponse)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'identity_changed'});
|
const identityResponse=new Response(new ReadableStream({pull(c){current=false;c.enqueue(new Uint8Array([1]));}}));await assert.rejects(transport(identityResponse)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'identity_changed'});
|
||||||
});
|
});
|
||||||
|
// Foundry authority 走 metadata 和原字节两条请求路径,验证已支持的版本及 digest;未来元数据版本明确拒绝解释。
|
||||||
test('Foundry resource authority validates version, resource identity and canonical URL',async()=>{
|
test('Foundry resource authority validates version, resource identity and canonical URL',async()=>{
|
||||||
const id='22222222-2222-4222-8222-222222222222',workspaceId='33333333-3333-4333-8333-333333333333';let version=1;const bytes=new Blob(['original']),sha256=await digest(bytes),calls=[];
|
const id='22222222-2222-4222-8222-222222222222',workspaceId='33333333-3333-4333-8333-333333333333';let version=1;const bytes=new Blob(['original']),sha256=await digest(bytes),calls=[];
|
||||||
const assets=createFoundryAssets({workspaceId,transport:async request=>{calls.push(request);return request.responseFormat==='asset-bytes-v1'?{status:200,body:null,bytes}:{status:200,body:{metadata_version:version,sha256,asset:{id,url:assets.sourceURL(id),width:2,height:1,bytes:bytes.size,mime_type:'image/png'}}};}});
|
const assets=createFoundryAssets({workspaceId,transport:async request=>{calls.push(request);return request.responseFormat==='asset-bytes-v1'?{status:200,body:null,bytes}:{status:200,body:{metadata_version:version,sha256,asset:{id,url:assets.sourceURL(id),width:2,height:1,bytes:bytes.size,mime_type:'image/png'}}};}});
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Foundry 上传 adapter 与 multipart transport 的协议测试。冻结请求关联资源 ID、操作 ID、摘要及字节长度。
|
||||||
|
* 回执未知或权限失败不等于零写入,显示地址可由宿主路由,但 canonical 身份和 authority 必须保持。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createFoundryAssets, createFoundryTransport } from '../../src/lib/canvas-service/index.ts';
|
import { createFoundryAssets, createFoundryTransport } from '../../src/lib/canvas-service/index.ts';
|
||||||
|
// 生成固定格式 UUID,满足 adapter 的身份校验且让不同角色的 ID 易于区分。
|
||||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||||
|
// 构造真实 Blob、摘要、冻结上传 body 与匹配成功回执;respond 只替换模拟服务端响应。
|
||||||
async function fixture() {
|
async function fixture() {
|
||||||
const blob = new Blob(['immutable image'], { type: 'image/png' });
|
const blob = new Blob(['immutable image'], { type: 'image/png' });
|
||||||
const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join('');
|
const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join('');
|
||||||
const resource = { id: id(1), operationId: id(2), name: 'picture.png', byteLength: blob.size, mimeType: blob.type, digest };
|
const resource = { id: id(1), operationId: id(2), name: 'picture.png', byteLength: blob.size, mimeType: blob.type, digest };
|
||||||
|
// 上传没有 canvas writer,但仍拥有 requestId/operationId 和不可变协议字段,不能重试时换身份。
|
||||||
const request = { kind: 'asset.upload', protocolVersion: 2, id: id(3), operationIds: [resource.operationId], canvasIds: [], expectedVersions: {}, phase: 'frozen', outcome: 'unresolved', generation: 1, attempt: 0,
|
const request = { kind: 'asset.upload', protocolVersion: 2, id: id(3), operationIds: [resource.operationId], canvasIds: [], expectedVersions: {}, phase: 'frozen', outcome: 'unresolved', generation: 1, attempt: 0,
|
||||||
body: JSON.stringify({ protocol_version: 2, request_id: id(3), resource_id: resource.id, operation_id: resource.operationId, name: resource.name, mime_type: resource.mimeType, byte_length: resource.byteLength, sha256: resource.digest }) };
|
body: JSON.stringify({ protocol_version: 2, request_id: id(3), resource_id: resource.id, operation_id: resource.operationId, name: resource.name, mime_type: resource.mimeType, byte_length: resource.byteLength, sha256: resource.digest }) };
|
||||||
|
// 成功回执使用当前 workspace 的规范 asset URL;测试会分别篡改资源身份、大小、地址和尺寸。
|
||||||
const calls = [], receipt = { protocol_version: 2, request_id: request.id, status: 'applied', asset: { id: resource.id, bytes: resource.byteLength, mime_type: 'image/png', width: 2, height: 1, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${id(4)}/assets/${resource.id}` } };
|
const calls = [], receipt = { protocol_version: 2, request_id: request.id, status: 'applied', asset: { id: resource.id, bytes: resource.byteLength, mime_type: 'image/png', width: 2, height: 1, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${id(4)}/assets/${resource.id}` } };
|
||||||
let response = { status: 201, body: receipt };
|
let response = { status: 201, body: receipt };
|
||||||
const assets = createFoundryAssets({ workspaceId: id(4), transport: async input => { calls.push(input); return response; } });
|
const assets = createFoundryAssets({ workspaceId: id(4), transport: async input => { calls.push(input); return response; } });
|
||||||
return { blob, resource, request, calls, receipt, assets, respond: next => { response = next; } };
|
return { blob, resource, request, calls, receipt, assets, respond: next => { response = next; } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上传保持文件名、原字节及请求身份;accept 拒绝不匹配回执,换字节重试在发请求前就被阻塞。
|
||||||
test('asset adapter preserves byte identity and headers and validates the scoped applied receipt', async () => {
|
test('asset adapter preserves byte identity and headers and validates the scoped applied receipt', async () => {
|
||||||
const f = await fixture(), result = await f.assets.upload(f.request, f.blob, new AbortController().signal);
|
const f = await fixture(), result = await f.assets.upload(f.request, f.blob, new AbortController().signal);
|
||||||
assert.equal(result.outcome, 'applied'); assert.equal(f.calls[0].requestId, f.request.id); assert.equal(f.calls[0].resourceId, f.resource.id);
|
assert.equal(result.outcome, 'applied'); assert.equal(f.calls[0].requestId, f.request.id); assert.equal(f.calls[0].resourceId, f.resource.id);
|
||||||
@@ -27,6 +36,7 @@ test('asset adapter preserves byte identity and headers and validates the scoped
|
|||||||
assert.equal(changed.outcome, 'unresolved'); assert.equal(changed.blocked, true); assert.equal(f.calls.length, 1);
|
assert.equal(changed.outcome, 'unresolved'); assert.equal(changed.blocked, true); assert.equal(f.calls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 宿主只替换 displayURL,不能改变 sourceURL 或 authority;单纯计算显示路径也不能触发网络。
|
||||||
test('a headless host supplies display URLs without changing canonical asset identity or authority',async()=>{
|
test('a headless host supplies display URLs without changing canonical asset identity or authority',async()=>{
|
||||||
const options={workspaceId:id(4),transport:async()=>{throw Error('display routing must not make a request');}};
|
const options={workspaceId:id(4),transport:async()=>{throw Error('display routing must not make a request');}};
|
||||||
const browser=createFoundryAssets(options),host=createFoundryAssets({...options,displayURL:asset=>`https://host.example/preview/${encodeURIComponent(asset)}`});
|
const browser=createFoundryAssets(options),host=createFoundryAssets({...options,displayURL:asset=>`https://host.example/preview/${encodeURIComponent(asset)}`});
|
||||||
@@ -35,6 +45,7 @@ test('a headless host supplies display URLs without changing canonical asset ide
|
|||||||
assert.equal(host.sourceURL(id(1)),browser.sourceURL(id(1)));assert.equal(host.authority.scope,browser.authority.scope);
|
assert.equal(host.sourceURL(id(1)),browser.sourceURL(id(1)));assert.equal(host.authority.scope,browser.authority.scope);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 查询 404 unknown 保持 unresolved、403 阻塞自动处理,成功响应若 requestId 错误仍不能当作已应用证明。
|
||||||
test('missing and rejected upload receipts never become proof of zero writes', async () => {
|
test('missing and rejected upload receipts never become proof of zero writes', async () => {
|
||||||
const f = await fixture(), signal = new AbortController().signal;
|
const f = await fixture(), signal = new AbortController().signal;
|
||||||
f.respond({ status: 404, body: { protocol_version: 2, request_id: f.request.id, status: 'unknown' } });
|
f.respond({ status: 404, body: { protocol_version: 2, request_id: f.request.id, status: 'unknown' } });
|
||||||
@@ -47,6 +58,7 @@ test('missing and rejected upload receipts never become proof of zero writes', a
|
|||||||
assert.equal((await f.assets.upload(f.request, f.blob, signal)).outcome, 'unresolved');
|
assert.equal((await f.assets.upload(f.request, f.blob, signal)).outcome, 'unresolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FormData 保持原对象交给 fetch,由 fetch 生成 multipart boundary;同时传入账户、资源和幂等身份头。
|
||||||
test('multipart browser transport leaves the boundary to fetch and binds the expected account', async () => {
|
test('multipart browser transport leaves the boundary to fetch and binds the expected account', async () => {
|
||||||
let captured;
|
let captured;
|
||||||
const transport = createFoundryTransport({ accountId: id(5), isCurrentIdentity: () => true, fetch: async (url, init) => { captured = { url, init }; return Response.json({ status: 'applied' }); } });
|
const transport = createFoundryTransport({ accountId: id(5), isCurrentIdentity: () => true, fetch: async (url, init) => { captured = { url, init }; return Response.json({ status: 'applied' }); } });
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 发送批次的稀疏变更压缩测试。wire操作保留全部逻辑ID,压缩只改变传输changes,不改ledger/history输入。
|
||||||
|
* 存在基线、前置条件、生命周期与UTF-8实际字节成本约束,不以“最终对象一样”就无条件消去中间意图。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDocument, applyChanges } from '../../src/lib/canvas-core/document.ts';
|
import { createDocument, applyChanges } from '../../src/lib/canvas-core/document.ts';
|
||||||
import { createLedger, compressChanges } from '../../src/lib/canvas-runtime/ledger.ts';
|
import { createLedger, compressChanges } from '../../src/lib/canvas-runtime/ledger.ts';
|
||||||
import { projectBatch } from '../../src/lib/canvas-runtime/batch-projection.ts';
|
import { projectBatch } from '../../src/lib/canvas-runtime/batch-projection.ts';
|
||||||
|
|
||||||
|
// root/child用于参与范围护栏对照,默认node同时含未来嵌套字段,检查位置压缩不覆盖业务数据。
|
||||||
const root='root', child='child';
|
const root='root', child='child';
|
||||||
const node=(id='a',ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'original',future:{keep:true}},geometry:{x:0,y:0,width:100,height:200,rotation:0}});
|
const node=(id='a',ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'original',future:{keep:true}},geometry:{x:0,y:0,width:100,height:200,rotation:0}});
|
||||||
const document=createDocument(root,[{id:root,parent:null}],[node()]);
|
const document=createDocument(root,[{id:root,parent:null}],[node()]);
|
||||||
|
/** 默认改a第一生命周期正文,可换字段/存在性标记,方便串起连续before→after。 */
|
||||||
const field=(before,after,key='data.text',extra={})=>({kind:'field',target:{id:'a',incarnation:1},field:key,before,after,...extra});
|
const field=(before,after,key='data.text',extra={})=>({kind:'field',target:{id:'a',incarnation:1},field:key,before,after,...extra});
|
||||||
|
// 放大中间值让确定安全的连续编辑压缩更可能减少真实wire体积。
|
||||||
const long='intermediate '.repeat(200);
|
const long='intermediate '.repeat(200);
|
||||||
|
/** 按顺序创建相依intent并入ledger,首次身份存在性来自base/显式presence;project允许缩小参与canvas范围。 */
|
||||||
function fixture(changes,base=document,presence){
|
function fixture(changes,base=document,presence){
|
||||||
const ledger=createLedger();
|
const ledger=createLedger();
|
||||||
const intents=changes.map((changes,index)=>({id:`op-${index}`,operationType:'document.change',operationSchemaVersion:1,sequence:index+1,origin:'user',canvasIds:[root],dependencies:index?[`op-${index-1}`]:[],changes}));
|
const intents=changes.map((changes,index)=>({id:`op-${index}`,operationType:'document.change',operationSchemaVersion:1,sequence:index+1,origin:'user',canvasIds:[root],dependencies:index?[`op-${index-1}`]:[],changes}));
|
||||||
@@ -16,8 +24,10 @@ function fixture(changes,base=document,presence){
|
|||||||
for(const intent of intents){const bases={};for(const change of intent.changes){if(change.kind==='canvas')continue;const id=change.kind==='field'?change.target.id:change.id;bases[id]=presence??(seen.has(id)||base.elements.has(id)?'exists':'absent');seen.add(id);}ledger.add(intent,bases);}
|
for(const intent of intents){const bases={};for(const change of intent.changes){if(change.kind==='canvas')continue;const id=change.kind==='field'?change.target.id:change.id;bases[id]=presence??(seen.has(id)||base.elements.has(id)?'exists':'absent');seen.add(id);}ledger.add(intent,bases);}
|
||||||
return{ledger,intents,project:(scope=[root])=>projectBatch(intents,base,ledger.getSnapshot(),scope)};
|
return{ledger,intents,project:(scope=[root])=>projectBatch(intents,base,ledger.getSnapshot(),scope)};
|
||||||
}
|
}
|
||||||
|
/** 文档转成元素/canvas/墓碑数组比较完整结果,避免只看最终正文而漏生命周期差异。 */
|
||||||
const values=document=>({elements:[...document.elements],canvases:[...document.canvases],tombstones:[...document.tombstones]});
|
const values=document=>({elements:[...document.elements],canvases:[...document.canvases],tombstones:[...document.tombstones]});
|
||||||
|
|
||||||
|
// original→长中间值→final可合成一个字段修改,但两逻辑ID都在;比较wire前后ledger及完整apply结果。
|
||||||
test('wire projection composes long field edits, preserves every original logical ID and leaves ledger/history inputs untouched',()=>{
|
test('wire projection composes long field edits, preserves every original logical ID and leaves ledger/history inputs untouched',()=>{
|
||||||
const f=fixture([[field('original',long)],[field(long,'final')]]),before=JSON.stringify([...f.ledger.getSnapshot().operations]),wire=f.project();
|
const f=fixture([[field('original',long)],[field(long,'final')]]),before=JSON.stringify([...f.ledger.getSnapshot().operations]),wire=f.project();
|
||||||
assert.deepEqual(wire.map(op=>op.id),f.intents.map(op=>op.id));
|
assert.deepEqual(wire.map(op=>op.id),f.intents.map(op=>op.id));
|
||||||
@@ -28,12 +38,14 @@ test('wire projection composes long field edits, preserves every original logica
|
|||||||
assert.equal(applyChanges(document,wire.flatMap(op=>op.changes)).elements.get('a').data.future.keep,true);
|
assert.equal(applyChanges(document,wire.flatMap(op=>op.changes)).elements.get('a').data.future.keep,true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 几何合成保留起始before,最终坐标正确且title/future业务数据完整,不用最后节点覆盖整对象。
|
||||||
test('position-only compaction keeps title, opaque data and the original before condition',()=>{
|
test('position-only compaction keeps title, opaque data and the original before condition',()=>{
|
||||||
const start=node().geometry,middle={...start,x:12345,y:67890},final={...start,x:3,y:4},f=fixture([[field(start,middle,'geometry')],[field(middle,final,'geometry')]]),wire=f.project();
|
const start=node().geometry,middle={...start,x:12345,y:67890},final={...start,x:3,y:4},f=fixture([[field(start,middle,'geometry')],[field(middle,final,'geometry')]]),wire=f.project();
|
||||||
assert.equal(wire.at(-1).changes.length,1);assert.deepEqual(wire.at(-1).changes[0].before,start);
|
assert.equal(wire.at(-1).changes.length,1);assert.deepEqual(wire.at(-1).changes[0].before,start);
|
||||||
const result=applyChanges(document,wire.flatMap(op=>op.changes));assert.deepEqual(result.elements.get('a').data,node().data);assert.deepEqual(result.elements.get('a').geometry,final);
|
const result=applyChanges(document,wire.flatMap(op=>op.changes));assert.deepEqual(result.elements.get('a').data,node().data);assert.deepEqual(result.elements.get('a').geometry,final);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// new创建/更新/删除最终无节点但有墓碑;wire仍留创建与删除,不把生命周期错误压成无操作。
|
||||||
test('created records absorb checked updates while create/delete keeps tombstone and lifecycle evidence',()=>{
|
test('created records absorb checked updates while create/delete keeps tombstone and lifecycle evidence',()=>{
|
||||||
const created={...node('new'),data:{title:'new',text:long}},updated={...created,data:{...created.data,text:'final'}};
|
const created={...node('new'),data:{title:'new',text:long}},updated={...created,data:{...created.data,text:'final'}};
|
||||||
const f=fixture([[{kind:'element',id:'new',before:null,after:created}],[{...field(long,'final'),target:{id:'new',incarnation:1}}],[{kind:'element',id:'new',before:updated,after:null}]]),wire=f.project();
|
const f=fixture([[{kind:'element',id:'new',before:null,after:created}],[{...field(long,'final'),target:{id:'new',incarnation:1}}],[{kind:'element',id:'new',before:updated,after:null}]]),wire=f.project();
|
||||||
@@ -42,6 +54,7 @@ test('created records absorb checked updates while create/delete keeps tombstone
|
|||||||
assert.deepEqual(values(result),values(applyChanges(document,f.intents.flatMap(op=>op.changes))));
|
assert.deepEqual(values(result),values(applyChanges(document,f.intents.flatMap(op=>op.changes))));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 可选字段从absent到值再absent,净值虽零仍保留前置存在性检查,apply结果与逐项操作相同。
|
||||||
test('net-zero field changes retain conditional presence checks; omitted true flags compose with explicit true flags',()=>{
|
test('net-zero field changes retain conditional presence checks; omitted true flags compose with explicit true flags',()=>{
|
||||||
const changes=[field(null,long,'data.optional',{beforePresent:false}),field(long,null,'data.optional',{beforePresent:true,afterPresent:false})];
|
const changes=[field(null,long,'data.optional',{beforePresent:false}),field(long,null,'data.optional',{beforePresent:true,afterPresent:false})];
|
||||||
const compressed=compressChanges(changes,{preserveChecks:true,preserveLifecycle:true});assert.equal(compressed.length,1);assert.equal(compressed[0].beforePresent,false);assert.equal(compressed[0].afterPresent,false);
|
const compressed=compressChanges(changes,{preserveChecks:true,preserveLifecycle:true});assert.equal(compressed.length,1);assert.equal(compressed[0].beforePresent,false);assert.equal(compressed[0].afterPresent,false);
|
||||||
@@ -49,6 +62,7 @@ test('net-zero field changes retain conditional presence checks; omitted true fl
|
|||||||
const f=fixture([[changes[0]],[changes[1]]]);assert.equal(f.project().at(-1).changes[0].beforePresent,false);
|
const f=fixture([[changes[0]],[changes[1]]]);assert.equal(f.project().at(-1).changes[0].beforePresent,false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// unknown/absent矛盾、缺基线、已冻结或blocked都回退原始顺序,不擅自重压可能发送的意图。
|
||||||
test('unknown or inconsistent baselines and frozen or blocked records retain ordered original changes',()=>{
|
test('unknown or inconsistent baselines and frozen or blocked records retain ordered original changes',()=>{
|
||||||
for(const presence of ['unknown','absent']){
|
for(const presence of ['unknown','absent']){
|
||||||
const f=fixture([[field('original',long)],[field(long,'final')]],document,presence);assert.deepEqual(f.project(),f.intents.map(({id,changes})=>({id,changes})));
|
const f=fixture([[field('original',long)],[field(long,'final')]],document,presence);assert.deepEqual(f.project(),f.intents.map(({id,changes})=>({id,changes})));
|
||||||
@@ -63,12 +77,14 @@ test('unknown or inconsistent baselines and frozen or blocked records retain ord
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 只允许child参与时不能借root条件证明压缩安全,也不能为了压缩扩大writer参与范围。
|
||||||
test('empty child creation does not borrow a parent canvas guard or widen participant claims',()=>{
|
test('empty child creation does not borrow a parent canvas guard or widen participant claims',()=>{
|
||||||
const base=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'frame'}}],[{...node('frame'),componentId:'frame'}]);
|
const base=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'frame'}}],[{...node('frame'),componentId:'frame'}]);
|
||||||
const created={...node('new',child),data:{title:'new',text:long}},f=fixture([[{kind:'element',id:'new',before:null,after:created}],[{...field(long,'final'),target:{id:'new',incarnation:1}}]],base);
|
const created={...node('new',child),data:{title:'new',text:long}},f=fixture([[{kind:'element',id:'new',before:null,after:created}],[{...field(long,'final'),target:{id:'new',incarnation:1}}]],base);
|
||||||
assert.deepEqual(f.project([child]),f.intents.map(({id,changes})=>({id,changes})));
|
assert.deepEqual(f.project([child]),f.intents.map(({id,changes})=>({id,changes})));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建后更新before不符或incarnation错误,压缩函数和batch都须拒绝,不能吸收进create掩盖冲突。
|
||||||
test('invalid original before values and stale incarnations cannot become successful compacted creates',()=>{
|
test('invalid original before values and stale incarnations cannot become successful compacted creates',()=>{
|
||||||
const created={...node('new'),data:{title:'new',text:long}};
|
const created={...node('new'),data:{title:'new',text:long}};
|
||||||
for(const change of [{...field('wrong','final'),target:{id:'new',incarnation:1}},{...field(long,'final'),target:{id:'new',incarnation:2}}]){
|
for(const change of [{...field('wrong','final'),target:{id:'new',incarnation:1}},{...field(long,'final'),target:{id:'new',incarnation:2}}]){
|
||||||
@@ -77,6 +93,7 @@ test('invalid original before values and stale incarnations cannot become succes
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 禁止完整表遍历及读取untouched,修改a仍可完成;稀疏证明只访问相关身份。
|
||||||
test('projection checks only affected records and leaves a large untouched document outside its sparse proof',()=>{
|
test('projection checks only affected records and leaves a large untouched document outside its sparse proof',()=>{
|
||||||
const elements=new Map(document.elements);elements.set('untouched',{...node('untouched'),data:{title:'opaque',text:'large'}});
|
const elements=new Map(document.elements);elements.set('untouched',{...node('untouched'),data:{title:'opaque',text:'large'}});
|
||||||
const guarded={...document,elements:{get:id=>{assert.notEqual(id,'untouched');return elements.get(id);},has:id=>elements.has(id),[Symbol.iterator](){throw new Error('full_document_scan');}}};
|
const guarded={...document,elements:{get:id=>{assert.notEqual(id,'untouched');return elements.get(id);},has:id=>elements.has(id),[Symbol.iterator](){throw new Error('full_document_scan');}}};
|
||||||
@@ -84,6 +101,7 @@ test('projection checks only affected records and leaves a large untouched docum
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 表情字符使guard UTF-8成本增加,按字节判定后应放弃无收益压缩,不能只比较JS字符串长度。
|
||||||
test('wire cost uses UTF-8 bytes, so a Unicode guard cannot turn a shorter JS string into a larger request',()=>{
|
test('wire cost uses UTF-8 bytes, so a Unicode guard cannot turn a shorter JS string into a larger request',()=>{
|
||||||
const a={...node('a',child),data:{title:'😀'.repeat(50),text:'😀'.repeat(50),blob:'x'}};
|
const a={...node('a',child),data:{title:'😀'.repeat(50),text:'😀'.repeat(50),blob:'x'}};
|
||||||
const base=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'frame'}}],[{...node('frame'),componentId:'frame'},a]);
|
const base=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'frame'}}],[{...node('frame'),componentId:'frame'},a]);
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* bindings与宿主生命周期的组合测试。涵盖树投影、坐标/曲线转换、连接手势、账户代次与工作区关闭,以及未知类型占位。
|
||||||
|
* 部分用例用内存storage/persistence演示持久化接口,不是磁盘崩溃或真实HTTP集成验证。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDocument, createEditor, createRegistry, builtinComponents, createSessions } from '../../src/lib/canvas-core/index.ts';
|
import { createDocument, createEditor, createRegistry, builtinComponents, createSessions } from '../../src/lib/canvas-core/index.ts';
|
||||||
import { projectCanvas, toLocal, toWorld, transformPath } from '../../src/lib/canvas-bindings/projection.ts';
|
import { projectCanvas, toLocal, toWorld, transformPath } from '../../src/lib/canvas-bindings/projection.ts';
|
||||||
import { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
|
import { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
|
||||||
const registry = createRegistry(builtinComponents());
|
const registry = createRegistry(builtinComponents());
|
||||||
|
/** 合法节点工厂,可指定owner、组件与几何,搭建父入口和子节点。 */
|
||||||
const node = (id, ownerCanvasId, componentId = 'text', geometry = {}) => ({ id, incarnation: 1, structure: 'node', ownerCanvasId, componentId, dataVersion: 1, data: { title: id, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0, ...geometry } });
|
const node = (id, ownerCanvasId, componentId = 'text', geometry = {}) => ({ id, incarnation: 1, structure: 'node', ownerCanvasId, componentId, dataVersion: 1, data: { title: id, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0, ...geometry } });
|
||||||
|
/** 父canvas所有的跨层边,从outside连到child内inside;折叠仅改变显示端点。 */
|
||||||
const edge = { id: 'edge', incarnation: 1, structure: 'connection', ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, source: { elementId: 'outside' }, target: { elementId: 'inside' }, path: null };
|
const edge = { id: 'edge', incarnation: 1, structure: 'connection', ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, source: { elementId: 'outside' }, target: { elementId: 'inside' }, path: null };
|
||||||
|
// frame位于(100,200),child有默认内边距,inside局部(10,20)投影应到(134,256)。
|
||||||
const document = createDocument('project', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [node('frame', 'root', 'frame', { x: 100, y: 200, width: 640, height: 400 }), node('inside', 'child', 'text', { x: 10, y: 20 }), node('outside', 'root', 'text', { x: 900 }), edge]);
|
const document = createDocument('project', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [node('frame', 'root', 'frame', { x: 100, y: 200, width: 640, height: 400 }), node('inside', 'child', 'text', { x: 10, y: 20 }), node('outside', 'root', 'text', { x: 900 }), edge]);
|
||||||
|
|
||||||
|
// 根视图显示子节点偏移,折叠把边显示端点转frame但文档仍inside;独立打开child使用原局部坐标。
|
||||||
test('canvas projection uses owner-local geometry and stable IDs; collapse changes only display endpoints', () => {
|
test('canvas projection uses owner-local geometry and stable IDs; collapse changes only display endpoints', () => {
|
||||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor); const detach = sessions.attach('view', 'root');
|
const editor = createEditor({ document, registry }), sessions = createSessions(editor); const detach = sessions.attach('view', 'root');
|
||||||
let projection = projectCanvas(document, 'root', registry, new Set(['inside']), sessions.effectiveGeometry);
|
let projection = projectCanvas(document, 'root', registry, new Set(['inside']), sessions.effectiveGeometry);
|
||||||
@@ -23,6 +31,7 @@ test('canvas projection uses owner-local geometry and stable IDs; collapse chang
|
|||||||
detach(); sessions.dispose();
|
detach(); sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 90度/0.5倍变换点和控制向量往返误差小于1e-9,extension未知字段保留。
|
||||||
test('rotated/scaled canvas transforms and curve controls round-trip without changing opaque path fields', () => {
|
test('rotated/scaled canvas transforms and curve controls round-trip without changing opaque path fields', () => {
|
||||||
const transform = { x: 100, y: 200, scale: .5, rotation: 90 }, point = { x: 40, y: 80 };
|
const transform = { x: 100, y: 200, scale: .5, rotation: 90 }, point = { x: 40, y: 80 };
|
||||||
const round = toLocal(transform, toWorld(transform, point)); assert.ok(Math.abs(round.x - point.x) < 1e-9 && Math.abs(round.y - point.y) < 1e-9);
|
const round = toLocal(transform, toWorld(transform, point)); assert.ok(Math.abs(round.x - point.x) < 1e-9 && Math.abs(round.y - point.y) < 1e-9);
|
||||||
@@ -31,6 +40,7 @@ test('rotated/scaled canvas transforms and curve controls round-trip without cha
|
|||||||
const local = transformPath(transformed, transform, true); assert.ok(Math.abs(local.knots[0].position.x - 40) < 1e-9);
|
const local = transformPath(transformed, transform, true); assert.ok(Math.abs(local.knots[0].position.x - 40) < 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 一视图占连接手势时另一视图节点/连接begin都busy;20次预览不记历史,提交一次,detach只取消后来预览。
|
||||||
test('connection drag owns the project gesture, previews without history, commits once and cancels on detach', () => {
|
test('connection drag owns the project gesture, previews without history, commits once and cancels on detach', () => {
|
||||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor), target = { id: 'edge', incarnation: 1 };
|
const editor = createEditor({ document, registry }), sessions = createSessions(editor), target = { id: 'edge', incarnation: 1 };
|
||||||
const detach = sessions.attach('one'), detach2 = sessions.attach('two');
|
const detach = sessions.attach('one'), detach2 = sessions.attach('two');
|
||||||
@@ -47,6 +57,7 @@ test('connection drag owns the project gesture, previews without history, commit
|
|||||||
detach2(); sessions.dispose();
|
detach2(); sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同账号workspace复用,切号使旧current失效并增authEpoch;retire在await close前先封身份,host关闭后不可再取workspace。
|
||||||
test('host scopes account epochs, preserves workspace owners across navigation, and fences before asynchronous close', async () => {
|
test('host scopes account epochs, preserves workspace owners across navigation, and fences before asynchronous close', async () => {
|
||||||
const created = [], closes = [];
|
const created = [], closes = [];
|
||||||
const host = createRuntimeHost({ registry, workspace(accountId, workspaceId, authEpoch, current) { const value = { accountId, workspaceId, authEpoch, current, async close() { closes.push(current()); } }; created.push(value); return value; } });
|
const host = createRuntimeHost({ registry, workspace(accountId, workspaceId, authEpoch, current) { const value = { accountId, workspaceId, authEpoch, current, async close() { closes.push(current()); } }; created.push(value); return value; } });
|
||||||
@@ -57,6 +68,7 @@ test('host scopes account epochs, preserves workspace owners across navigation,
|
|||||||
await host.close(); assert.throws(() => host.workspace('account-b', 'workspace'), /host_disposed/);
|
await host.close(); assert.throws(() => host.workspace('account-b', 'workspace'), /host_disposed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远端path覆盖使连接预览取消,已提交remote路径保持且不新建本地历史。
|
||||||
test('late connection changes cancel preview without restoring stale path or unrelated appearance', () => {
|
test('late connection changes cancel preview without restoring stale path or unrelated appearance', () => {
|
||||||
const editor = createEditor({ document, registry, allowRawChanges: true }), sessions = createSessions(editor); sessions.attach('view');
|
const editor = createEditor({ document, registry, allowRawChanges: true }), sessions = createSessions(editor); sessions.attach('view');
|
||||||
sessions.beginConnection('view', { id: 'edge', incarnation: 1 }); sessions.updateConnection('view', { type: 'bezier-chain', knots: [] });
|
sessions.beginConnection('view', { id: 'edge', incarnation: 1 }); sessions.updateConnection('view', { type: 'bezier-chain', knots: [] });
|
||||||
@@ -66,6 +78,7 @@ test('late connection changes cancel preview without restoring stale path or unr
|
|||||||
assert.equal(editor.getSnapshot().history.undo.length, 0); sessions.dispose();
|
assert.equal(editor.getSnapshot().history.undo.length, 0); sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 两并发open复用engine,detach不丢文档;存储quota使close失败,leader/资源池保留但新编辑拒绝,修复后重试才释放。
|
||||||
test('workspace retains a failed close for retry and stops edits before releasing its persistent owner', async () => {
|
test('workspace retains a failed close for retry and stops edits before releasing its persistent owner', async () => {
|
||||||
const { createWorkspaceRuntime } = await import('../../src/lib/canvas-runtime/workspace.ts');
|
const { createWorkspaceRuntime } = await import('../../src/lib/canvas-runtime/workspace.ts');
|
||||||
let image = null, leader, broken = false, closed = false, digestClosed=false, server = document, version = 1;
|
let image = null, leader, broken = false, closed = false, digestClosed=false, server = document, version = 1;
|
||||||
@@ -84,6 +97,7 @@ test('workspace retains a failed close for retry and stops edits before releasin
|
|||||||
broken = false; await workspace.close(); assert.equal(leader.active, false); assert.equal(closed, true);assert.equal(digestClosed,true);
|
broken = false; await workspace.close(); assert.equal(leader.active, false); assert.equal(closed, true);assert.equal(digestClosed,true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未来path允许外观编辑但拒绝路径重写,文档原path保留,兼容投影不解释其控制点。
|
||||||
test('future curve formats remain readable and opaque while appearance edits preserve their path', () => {
|
test('future curve formats remain readable and opaque while appearance edits preserve their path', () => {
|
||||||
const future = { ...edge, path: { type: 'future-curve', opaque: { retained: true } } };
|
const future = { ...edge, path: { type: 'future-curve', opaque: { retained: true } } };
|
||||||
const source = createDocument('project', [...document.canvases.values()], [...document.elements.values()].filter(element => element.id !== 'edge').concat(future));
|
const source = createDocument('project', [...document.canvases.values()], [...document.elements.values()].filter(element => element.id !== 'edge').concat(future));
|
||||||
@@ -94,10 +108,12 @@ test('future curve formats remain readable and opaque while appearance edits pre
|
|||||||
assert.equal(projectCanvas(editor.getSnapshot().document, 'root', registry, new Set(), id => editor.getSnapshot().document.elements.get(id)?.geometry).edges[0].data.path, undefined);
|
assert.equal(projectCanvas(editor.getSnapshot().document, 'root', registry, new Set(), id => editor.getSnapshot().document.elements.get(id)?.geometry).edges[0].data.path, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 第一lease取消后启动第二场,旧preview/commit/cancel都不得改变第二场x=200或history,只有新lease能提交。
|
||||||
test('a canceled pointer lease cannot update, finish or cancel a newer gesture in the same view', async () => {
|
test('a canceled pointer lease cannot update, finish or cancel a newer gesture in the same view', async () => {
|
||||||
const { guardEditingLease } = await import('../../src/components/canvas/editing-lease.ts');
|
const { guardEditingLease } = await import('../../src/components/canvas/editing-lease.ts');
|
||||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor); sessions.attach('view');
|
const editor = createEditor({ document, registry }), sessions = createSessions(editor); sessions.attach('view');
|
||||||
const target = { id: 'outside', incarnation: 1 };
|
const target = { id: 'outside', incarnation: 1 };
|
||||||
|
/** 捕获当前gesture token包装三动作;旧lease必须在下一场手势开始后全部变为无效调用。 */
|
||||||
const lease = () => { const token = sessions.getSnapshot().gesture.token; return guardEditingLease(() => sessions.getSnapshot().gesture?.token === token, { preview: patch => sessions.update('view', target, patch), commit: () => sessions.commit('view'), cancel: () => sessions.cancel('pointer_cancel') }); };
|
const lease = () => { const token = sessions.getSnapshot().gesture.token; return guardEditingLease(() => sessions.getSnapshot().gesture?.token === token, { preview: patch => sessions.update('view', target, patch), commit: () => sessions.commit('view'), cancel: () => sessions.cancel('pointer_cancel') }); };
|
||||||
sessions.begin('view', [target]); const first = lease(); first.preview({ x: 100 }); first.cancel();
|
sessions.begin('view', [target]); const first = lease(); first.preview({ x: 100 }); first.cancel();
|
||||||
sessions.begin('view', [target]); const second = lease(); second.preview({ x: 200 });
|
sessions.begin('view', [target]); const second = lease(); second.preview({ x: 200 });
|
||||||
@@ -107,18 +123,22 @@ test('a canceled pointer lease cannot update, finish or cancel a newer gesture i
|
|||||||
sessions.dispose();
|
sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 构造未知/未来节点与边的异常业务字段,投影只读安全包络并按边补去重Handle;原文档序列化值不变。
|
||||||
test('unknown payloads project safe placeholders and retained endpoint handles without interpreting their fields',()=>{
|
test('unknown payloads project safe placeholders and retained endpoint handles without interpreting their fields',()=>{
|
||||||
const unknown={...node('unknown','root','plugin:future'),dataVersion:7,data:{title:'Unknown',ports:{find:'opaque'},presentation:{unitScale:'future'},assetId:{future:true},__headlessFallback:{handles:'opaque'},opaque:{retained:true}}},future={...node('future','root','text'),dataVersion:99,data:{title:'Future',text:{opaque:true},ports:'opaque'}},connection={...edge,componentId:'plugin:edge',dataVersion:7,source:{elementId:'unknown',portId:'plugin:out'},target:{elementId:'future',portId:'plugin:in'},data:{appearance:{color:{future:true}}},path:{type:'future'}};
|
const unknown={...node('unknown','root','plugin:future'),dataVersion:7,data:{title:'Unknown',ports:{find:'opaque'},presentation:{unitScale:'future'},assetId:{future:true},__headlessFallback:{handles:'opaque'},opaque:{retained:true}}},future={...node('future','root','text'),dataVersion:99,data:{title:'Future',text:{opaque:true},ports:'opaque'}},connection={...edge,componentId:'plugin:edge',dataVersion:7,source:{elementId:'unknown',portId:'plugin:out'},target:{elementId:'future',portId:'plugin:in'},data:{appearance:{color:{future:true}}},path:{type:'future'}};
|
||||||
const source=createDocument('project',[{id:'root',parent:null}],[unknown,future,connection,{...connection,id:'repeat'}]),before=JSON.stringify([...source.elements]),p=projectCanvas(source,'root',registry,new Set(),()=>undefined),u=p.nodes.find(row=>row.id==='unknown'),f=p.nodes.find(row=>row.id==='future');
|
const source=createDocument('project',[{id:'root',parent:null}],[unknown,future,connection,{...connection,id:'repeat'}]),before=JSON.stringify([...source.elements]),p=projectCanvas(source,'root',registry,new Set(),()=>undefined),u=p.nodes.find(row=>row.id==='unknown'),f=p.nodes.find(row=>row.id==='future');
|
||||||
assert.equal(u.type,'unsupported');assert.equal(u.draggable,false);assert.equal(u.data.ports,undefined);assert.equal(u.data.assetId,undefined);assert.equal(u.data.__headlessFallback.reason,'unknown-component');assert.equal(u.data.__headlessFallback.handles.filter(row=>row.id==='plugin:out').length,1);assert.ok(u.data.__headlessFallback.handles.some(row=>row.id==='plugin:out'&&row.side==='right'));assert.equal(f.data.__headlessFallback.reason,'unsupported-version');assert.ok(f.data.__headlessFallback.handles.some(row=>row.id==='plugin:in'&&row.side==='left'));assert.equal(p.edges[0].type,'unsupported');assert.deepEqual(p.edges[0].data,{});assert.equal(JSON.stringify([...source.elements]),before);
|
assert.equal(u.type,'unsupported');assert.equal(u.draggable,false);assert.equal(u.data.ports,undefined);assert.equal(u.data.assetId,undefined);assert.equal(u.data.__headlessFallback.reason,'unknown-component');assert.equal(u.data.__headlessFallback.handles.filter(row=>row.id==='plugin:out').length,1);assert.ok(u.data.__headlessFallback.handles.some(row=>row.id==='plugin:out'&&row.side==='right'));assert.equal(f.data.__headlessFallback.reason,'unsupported-version');assert.ok(f.data.__headlessFallback.handles.some(row=>row.id==='plugin:in'&&row.side==='left'));assert.equal(p.edges[0].type,'unsupported');assert.deepEqual(p.edges[0].data,{});assert.equal(JSON.stringify([...source.elements]),before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未来frame隐藏内部显示树并重定向上级边,保留占位Handle;真实端点与子树数据不删除。
|
||||||
test('future partition placeholders collapse only the display tree and expose redirected upper-canvas edges',()=>{
|
test('future partition placeholders collapse only the display tree and expose redirected upper-canvas edges',()=>{
|
||||||
const future=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(row=>row.id==='frame'?{...row,dataVersion:99,data:{...row.data,ports:{opaque:true},presentation:{unitScale:'future'}}}:row)),p=projectCanvas(future,'root',registry,new Set(),()=>undefined),frame=p.nodes.find(row=>row.id==='frame');assert.equal(frame.type,'unsupported');assert.equal(frame.style.pointerEvents,undefined);assert.equal(p.nodes.some(row=>row.id==='inside'),false);assert.equal(p.edges[0].target,'frame');assert.equal(p.edges[0].targetHandle,'left');assert.ok(frame.data.__headlessFallback.handles.some(row=>row.id==='left'));assert.equal(future.elements.get('edge').target.elementId,'inside');
|
const future=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(row=>row.id==='frame'?{...row,dataVersion:99,data:{...row.data,ports:{opaque:true},presentation:{unitScale:'future'}}}:row)),p=projectCanvas(future,'root',registry,new Set(),()=>undefined),frame=p.nodes.find(row=>row.id==='frame');assert.equal(frame.type,'unsupported');assert.equal(frame.style.pointerEvents,undefined);assert.equal(p.nodes.some(row=>row.id==='inside'),false);assert.equal(p.edges[0].target,'frame');assert.equal(p.edges[0].targetHandle,'left');assert.ok(frame.data.__headlessFallback.handles.some(row=>row.id==='left'));assert.equal(future.elements.get('edge').target.elementId,'inside');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 已知自定义组件无renderer显示占位,但安装了movable仍可拖/dispatch移动,不能一律当未知不可编辑。
|
||||||
test('known components without a matching renderer retain registered geometry capability instead of using the text editor',async()=>{
|
test('known components without a matching renderer retain registered geometry capability instead of using the text editor',async()=>{
|
||||||
const {defineComponent,movable}=await import('../../src/lib/canvas-core/index.ts'),custom=defineComponent({id:'plugin:known',dataVersion:1,structure:'node',validate:()=>true,features:[movable()]}),r=createRegistry([...builtinComponents(),custom]),source=createDocument('project',[{id:'root',parent:null}],[node('custom','root','plugin:known')]);
|
const {defineComponent,movable}=await import('../../src/lib/canvas-core/index.ts'),custom=defineComponent({id:'plugin:known',dataVersion:1,structure:'node',validate:()=>true,features:[movable()]}),r=createRegistry([...builtinComponents(),custom]),source=createDocument('project',[{id:'root',parent:null}],[node('custom','root','plugin:known')]);
|
||||||
const projected=projectCanvas(source,'root',r,new Set(),()=>undefined).nodes[0];assert.equal(projected.type,'unsupported');assert.equal(projected.draggable,true);assert.equal(projected.data.__headlessFallback.reason,'renderer-unavailable');const editor=createEditor({document:source,registry:r});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'custom',incarnation:1},capability:'geometry.move',patch:{x:20}}).status,'applied');
|
const projected=projectCanvas(source,'root',r,new Set(),()=>undefined).nodes[0];assert.equal(projected.type,'unsupported');assert.equal(projected.draggable,true);assert.equal(projected.data.__headlessFallback.reason,'renderer-unavailable');const editor=createEditor({document:source,registry:r});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'custom',incarnation:1},capability:'geometry.move',patch:{x:20}}).status,'applied');
|
||||||
});
|
});
|
||||||
|
// __headlessTarget由投影重新生成冻结,伪造data字段不能决定渲染身份,原模型仍原样保留。
|
||||||
test('rendered targets carry captured incarnation/owner/placement and overwrite spoofed data without mutating the model',()=>{const projection=projectCanvas(document,'root',registry,new Set(),()=>undefined),inside=projection.nodes.find(node=>node.id==='inside');assert.deepEqual(inside.data.__headlessTarget,{id:'inside',incarnation:1,ownerCanvasId:'child',canvasId:'root'});assert.ok(Object.isFrozen(inside.data.__headlessTarget));const raw={...node('safe','root'),incarnation:2,data:{title:'safe',text:'',__headlessTarget:{id:'spoof',incarnation:99,ownerCanvasId:'elsewhere',canvasId:'elsewhere'}}},source=createDocument('p',[{id:'root',parent:null}],[raw]),before=JSON.stringify([...source.elements]),drawn=projectCanvas(source,'root',registry,new Set(),()=>undefined).nodes[0];assert.deepEqual(drawn.data.__headlessTarget,{id:'safe',incarnation:2,ownerCanvasId:'root',canvasId:'root'});assert.equal(JSON.stringify([...source.elements]),before);});
|
test('rendered targets carry captured incarnation/owner/placement and overwrite spoofed data without mutating the model',()=>{const projection=projectCanvas(document,'root',registry,new Set(),()=>undefined),inside=projection.nodes.find(node=>node.id==='inside');assert.deepEqual(inside.data.__headlessTarget,{id:'inside',incarnation:1,ownerCanvasId:'child',canvasId:'root'});assert.ok(Object.isFrozen(inside.data.__headlessTarget));const raw={...node('safe','root'),incarnation:2,data:{title:'safe',text:'',__headlessTarget:{id:'spoof',incarnation:99,ownerCanvasId:'elsewhere',canvasId:'elsewhere'}}},source=createDocument('p',[{id:'root',parent:null}],[raw]),before=JSON.stringify([...source.elements]),drawn=projectCanvas(source,'root',registry,new Set(),()=>undefined).nodes[0];assert.deepEqual(drawn.data.__headlessTarget,{id:'safe',incarnation:2,ownerCanvasId:'root',canvasId:'root'});assert.equal(JSON.stringify([...source.elements]),before);});
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 连线绘制溢出与缓存回归。端点矩形不足以代表曲线范围,索引需包含控制多边形和描边;缺锚点、非法响应或预算不足必须unknown。
|
||||||
|
* 使用服务回调注入锚点/曲线/线宽,测试路径不依赖真实DOM测量或浏览器SVG绘制。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
||||||
import {projectedConnectionPaintOverflow} from '../../src/lib/canvas-bindings/connection-paint.ts';
|
import {projectedConnectionPaintOverflow} from '../../src/lib/canvas-bindings/connection-paint.ts';
|
||||||
import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts';
|
import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts';
|
||||||
|
// 两节点位于y=500,边可指定ID;几何服务可将曲线弯到视口上方以制造端点矩形外的绘制。
|
||||||
const registry=createRegistry(builtinComponents()),node=(id,x)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x,y:500,width:100,height:100,rotation:0}}),edge=(id='edge')=>({id,incarnation:1,ownerCanvasId:'p',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null});
|
const registry=createRegistry(builtinComponents()),node=(id,x)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x,y:500,width:100,height:100,rotation:0}}),edge=(id='edge')=>({id,incarnation:1,ownerCanvasId:'p',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null});
|
||||||
|
/** 每次新建等价文档/投影,检验缓存不能只靠输入引用相同判断可复用。 */
|
||||||
const draw=(edges=[edge()])=>projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),...edges]),'p',registry,new Set(),()=>undefined);
|
const draw=(edges=[edge()])=>projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),...edges]),'p',registry,new Set(),()=>undefined);
|
||||||
|
// 控制点到y=-500,10描边使保守范围扩5;查询端点之间屏幕外弯曲区域仍能找到edge,节点y保持500。
|
||||||
test('bowed cubic paint is indexed outside endpoint rectangles including stroke while document bounds remain unchanged',()=>{const projection=draw(),result=projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:100,y:-500},{x:500,y:-500},{x:500,y:550}]],strokeWidth:()=>10});assert.deepEqual(result.unknown,[]);assert.deepEqual(result.regions[0].bounds,{x:95,y:-505,width:410,height:1060});const index=createPaintIndex();index.sync(result.regions);assert.ok(index.query({x:250,y:0,width:10,height:10}).has('edge'));assert.equal(projection.document.elements.get('a').geometry.y,500);});
|
test('bowed cubic paint is indexed outside endpoint rectangles including stroke while document bounds remain unchanged',()=>{const projection=draw(),result=projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:100,y:-500},{x:500,y:-500},{x:500,y:550}]],strokeWidth:()=>10});assert.deepEqual(result.unknown,[]);assert.deepEqual(result.regions[0].bounds,{x:95,y:-505,width:410,height:1060});const index=createPaintIndex();index.sync(result.regions);assert.ok(index.query({x:250,y:0,width:10,height:10}).has('edge'));assert.equal(projection.document.elements.get('a').geometry.y,500);});
|
||||||
|
// 直线全被端点范围覆盖时无额外区域;缺/NaN锚点、非法段和无限线宽不能被当成无需绘制。
|
||||||
test('straight contained paint needs no overflow; missing anchors and invalid geometry stay unknown',()=>{const projection=draw();assert.equal(projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2}).regions.length,0);for(const services of [{anchor:()=>undefined},{anchor:()=>({point:{x:NaN,y:0},normal:{x:1,y:0}})},{edgeGeometry:()=>[[{x:NaN,y:0}]],strokeWidth:()=>1},{strokeWidth:()=>Infinity}])assert.deepEqual(projectedConnectionPaintOverflow(projection,registry,services).unknown,['edge']);});
|
test('straight contained paint needs no overflow; missing anchors and invalid geometry stay unknown',()=>{const projection=draw();assert.equal(projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2}).regions.length,0);for(const services of [{anchor:()=>undefined},{anchor:()=>({point:{x:NaN,y:0},normal:{x:1,y:0}})},{edgeGeometry:()=>[[{x:NaN,y:0}]],strokeWidth:()=>1},{strokeWidth:()=>Infinity}])assert.deepEqual(projectedConnectionPaintOverflow(projection,registry,services).unknown,['edge']);});
|
||||||
|
// 边或段预算超限用paint_budget/edge unknown报告;非法零预算拒绝,不伪称完整覆盖。
|
||||||
test('work budgets return unknown instead of treating omitted edges or excessive segments as invisible',()=>{const projection=draw([edge('one'),edge('two')]);assert.ok(projectedConnectionPaintOverflow(projection,registry,{},1).unknown.includes('paint_budget'));assert.deepEqual(projectedConnectionPaintOverflow(draw(),registry,{edgeGeometry:()=>[[],[]]},4096,1).unknown,['edge']);assert.throws(()=>projectedConnectionPaintOverflow(draw(),registry,{},0));});
|
test('work budgets return unknown instead of treating omitted edges or excessive segments as invisible',()=>{const projection=draw([edge('one'),edge('two')]);assert.ok(projectedConnectionPaintOverflow(projection,registry,{},1).unknown.includes('paint_budget'));assert.deepEqual(projectedConnectionPaintOverflow(draw(),registry,{edgeGeometry:()=>[[],[]]},4096,1).unknown,['edge']);assert.throws(()=>projectedConnectionPaintOverflow(draw(),registry,{},0));});
|
||||||
import {createConnectionPaintIndex} from '../../src/lib/canvas-bindings/connection-paint.ts';
|
import {createConnectionPaintIndex} from '../../src/lib/canvas-bindings/connection-paint.ts';
|
||||||
|
// 相同ACK/节点title不重算曲线;测量锚点、显式geometryRevision与incarnation变化需重算,删除边清缓存。
|
||||||
test('connection geometry cache skips equal ACKs and unrelated titles, invalidates measured anchors, stroke configuration and lifecycle',()=>{const cache=createConnectionPaintIndex();let geometryCalls=0,shift=0,width=2;const services={anchor:(node,_handle,end)=>({point:{x:node.position.x+(end==='source'?100:0)+shift,y:550},normal:{x:end==='source'?1:-1,y:0}}),edgeGeometry:(_edge,from,to)=>{geometryCalls++;return[[from.point,{x:200,y:550},{x:400,y:550},to.point]];},strokeWidth:()=>width};cache.sync(draw(),registry,services);assert.equal(geometryCalls,1);cache.sync(draw(),registry,services);assert.equal(geometryCalls,1);const p=draw();p.nodes[0].data.title='unrelated node title';cache.sync(p,registry,services);assert.equal(geometryCalls,1);shift=10;cache.sync(draw(),registry,services);assert.equal(geometryCalls,2);width=20;cache.sync(draw(),registry,services,'new-stroke-config');assert.equal(geometryCalls,3);const restored=projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),{...edge(),incarnation:2}]),'p',registry,new Set(),()=>undefined);cache.sync(restored,registry,services,'new-stroke-config');assert.equal(geometryCalls,4);cache.sync({...restored,edges:[]},registry,services,'new-stroke-config');assert.equal(cache.stats().entries,0);});
|
test('connection geometry cache skips equal ACKs and unrelated titles, invalidates measured anchors, stroke configuration and lifecycle',()=>{const cache=createConnectionPaintIndex();let geometryCalls=0,shift=0,width=2;const services={anchor:(node,_handle,end)=>({point:{x:node.position.x+(end==='source'?100:0)+shift,y:550},normal:{x:end==='source'?1:-1,y:0}}),edgeGeometry:(_edge,from,to)=>{geometryCalls++;return[[from.point,{x:200,y:550},{x:400,y:550},to.point]];},strokeWidth:()=>width};cache.sync(draw(),registry,services);assert.equal(geometryCalls,1);cache.sync(draw(),registry,services);assert.equal(geometryCalls,1);const p=draw();p.nodes[0].data.title='unrelated node title';cache.sync(p,registry,services);assert.equal(geometryCalls,1);shift=10;cache.sync(draw(),registry,services);assert.equal(geometryCalls,2);width=20;cache.sync(draw(),registry,services,'new-stroke-config');assert.equal(geometryCalls,3);const restored=projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),{...edge(),incarnation:2}]),'p',registry,new Set(),()=>undefined);cache.sync(restored,registry,services,'new-stroke-config');assert.equal(geometryCalls,4);cache.sync({...restored,edges:[]},registry,services,'new-stroke-config');assert.equal(cache.stats().entries,0);});
|
||||||
|
// 曲线已缓存也要计入新maxSegments预算,不能因不重算就绕过限制;clear清实际条目。
|
||||||
test('cached segment costs still respect a smaller budget instead of claiming complete coverage',()=>{const cache=createConnectionPaintIndex(),projection=draw([edge('one'),edge('two')]),services={edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2};cache.sync(projection,registry,services);const computed=cache.stats().computed,limited=cache.sync(projection,registry,services,undefined,{maxSegments:1});assert.ok(limited.unknown.includes('paint_budget'));assert.equal(cache.stats().computed,computed);cache.clear();assert.equal(cache.stats().entries,0);});
|
test('cached segment costs still respect a smaller budget instead of claiming complete coverage',()=>{const cache=createConnectionPaintIndex(),projection=draw([edge('one'),edge('two')]),services={edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2};cache.sync(projection,registry,services);const computed=cache.stats().computed,limited=cache.sync(projection,registry,services,undefined,{maxSegments:1});assert.ok(limited.unknown.includes('paint_budget'));assert.equal(cache.stats().computed,computed);cache.clear();assert.equal(cache.stats().entries,0);});
|
||||||
|
// 仅投影预览path弯到上方,document.path仍null;进入/退出预览都重算且查询范围随之恢复。
|
||||||
test('connection preview paths invalidate paint even while the committed connection record stays unchanged',()=>{const cache=createConnectionPaintIndex(),original=draw(),nearBow={x:250,y:-50,width:100,height:100};assert.equal(cache.sync(original,registry).query(nearBow).size,0);const preview=draw();preview.edges[0].data.path={type:'bezier-chain',knots:[{id:'preview-knot',mode:'corner',position:{x:300,y:-1000},in:{x:-100,y:0},out:{x:100,y:0}}]};const shown=cache.sync(preview,registry);assert.ok(shown.query(nearBow).has('edge'));assert.equal(preview.document.elements.get('edge').path,null);assert.equal(cache.sync(draw(),registry).query(nearBow).size,0);assert.equal(cache.stats().computed,3);});
|
test('connection preview paths invalidate paint even while the committed connection record stays unchanged',()=>{const cache=createConnectionPaintIndex(),original=draw(),nearBow={x:250,y:-50,width:100,height:100};assert.equal(cache.sync(original,registry).query(nearBow).size,0);const preview=draw();preview.edges[0].data.path={type:'bezier-chain',knots:[{id:'preview-knot',mode:'corner',position:{x:300,y:-1000},in:{x:-100,y:0},out:{x:100,y:0}}]};const shown=cache.sync(preview,registry);assert.ok(shown.query(nearBow).has('edge'));assert.equal(preview.document.elements.get('edge').path,null);assert.equal(cache.sync(draw(),registry).query(nearBow).size,0);assert.equal(cache.stats().computed,3);});
|
||||||
|
|
||||||
|
// 无边时代理禁止遍历nodes,溢出与缓存路径均直接返回空结果,避免全图无效工作。
|
||||||
test('an empty connection projection does not enumerate unrelated nodes',()=>{
|
test('an empty connection projection does not enumerate unrelated nodes',()=>{
|
||||||
const projection=draw();projection.edges=[];projection.nodes=new Proxy(projection.nodes,{get(target,key,receiver){if(key===Symbol.iterator||key==='map')throw Error('unrelated nodes enumerated');return Reflect.get(target,key,receiver);}});
|
const projection=draw();projection.edges=[];projection.nodes=new Proxy(projection.nodes,{get(target,key,receiver){if(key===Symbol.iterator||key==='map')throw Error('unrelated nodes enumerated');return Reflect.get(target,key,receiver);}});
|
||||||
assert.deepEqual(projectedConnectionPaintOverflow(projection,registry),{regions:[],unknown:[],computed:0});assert.equal(createConnectionPaintIndex().sync(projection,registry).unknown.length,0);
|
assert.deepEqual(projectedConnectionPaintOverflow(projection,registry),{regions:[],unknown:[],computed:0});assert.equal(createConnectionPaintIndex().sync(projection,registry).unknown.length,0);
|
||||||
});
|
});
|
||||||
|
// 重复ID按后者解析锚点,原地替换投影行也读新位置;缺b则两边unknown,较小边预算仍正确报告。
|
||||||
test('endpoint filtering preserves duplicate IDs, mutable replacement, missing targets and edge budgets',()=>{
|
test('endpoint filtering preserves duplicate IDs, mutable replacement, missing targets and edge budgets',()=>{
|
||||||
const projection=draw([edge('one'),edge('two')]),last={...projection.nodes[0],position:{x:900,y:500}},positions=[];
|
const projection=draw([edge('one'),edge('two')]),last={...projection.nodes[0],position:{x:900,y:500}},positions=[];
|
||||||
projection.nodes.push(last);const services={anchor:(node,_handle,end)=>{positions.push(node.position.x);return{point:{x:node.position.x,y:550},normal:{x:end==='source'?1:-1,y:0}};}};
|
projection.nodes.push(last);const services={anchor:(node,_handle,end)=>{positions.push(node.position.x);return{point:{x:node.position.x,y:550},normal:{x:end==='source'?1:-1,y:0}};}};
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 核心编辑器的组合回归测试。用内存文档与内置/自定义registry验证事务发布、history、组件兼容和临时手势,不连接服务端。
|
||||||
|
* 多数失败断言同时检查根对象身份,确保“拒绝”不仅返回错误,还没有发布中间文档/历史/运行元数据。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
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 };
|
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 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 });
|
const target = id => ({ id, incarnation: 1 });
|
||||||
|
/** 每个用例隔离editor并使用确定性操作ID;options可覆盖registry/document/policy等依赖。 */
|
||||||
function setup(elements = [node('a'), node('b')], options = {}) {
|
function setup(elements = [node('a'), node('b')], options = {}) {
|
||||||
let next = 0;
|
let next = 0;
|
||||||
return createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], elements), registry: createRegistry(builtinComponents()), createId: () => `op-${++next}`, ...options });
|
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', () => {
|
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) {
|
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');
|
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);
|
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', () => {
|
test('a rejected second command publishes no document, history, operation or notification', () => {
|
||||||
const editor = setup(), before = editor.getSnapshot(); let notifications = 0;
|
const editor = setup(), before = editor.getSnapshot(); let notifications = 0;
|
||||||
editor.subscribe(() => notifications++);
|
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);
|
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', () => {
|
test('can is pure and command handlers come from the component capability table', () => {
|
||||||
let ids = 0;
|
let ids = 0;
|
||||||
const frame = node('a', 'root', 'frame'), editor = setup([frame], { createId: () => String(++ids) });
|
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);
|
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',()=>{
|
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 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});
|
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);
|
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',()=>{
|
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 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'}});
|
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);
|
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 () => {
|
test('absent and explicit null are distinct history values; view selection follows element deletion', async () => {
|
||||||
const editor = setup();
|
const editor = setup();
|
||||||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } });
|
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);
|
assert.equal(sessions.getSnapshot().views.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 动态policy先允许预览,再通过runtime发布触发权限重查;取消原因和下一次begin都必须反映forbidden。
|
||||||
test('permission changes cancel geometry previews and prevent new gestures', async () => {
|
test('permission changes cancel geometry previews and prevent new gestures', async () => {
|
||||||
let permitted = true;
|
let permitted = true;
|
||||||
const editor = setup(undefined, { policy: () => permitted ? { enabled: true } : { enabled: false, code: 'forbidden', targets: ['a'] } });
|
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();
|
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', () => {
|
test('several commands are one history unit; data undo preserves a later resource acknowledgment', () => {
|
||||||
const editor = setup();
|
const editor = setup();
|
||||||
editor.transaction(tx => {
|
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');
|
assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored-asset');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远端后来改同一标题,撤销本地旧值必须拒绝,并保持当前根和栈不变。
|
||||||
test('same-field conflict does not pop history or overwrite remote edits', () => {
|
test('same-field conflict does not pop history or overwrite remote edits', () => {
|
||||||
const editor = setup();
|
const editor = setup();
|
||||||
editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'local' } });
|
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);
|
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', () => {
|
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 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]);
|
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);
|
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', () => {
|
test('new user edit clears redo, bounded history evicts old entries, no-op creates no operation', () => {
|
||||||
const editor = setup(undefined, { historyLimit: 2 });
|
const editor = setup(undefined, { historyLimit: 2 });
|
||||||
for (const title of ['one', 'two', 'three']) editor.dispatch({ type: 'element.data', target: target('a'), patch: { title } });
|
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);
|
assert.strictEqual(editor.getSnapshot(), before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 异步事务在await前后都有写入尝试;等待微任务后根仍不变,捕获的tx也已关闭。
|
||||||
test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => {
|
test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => {
|
||||||
const editor = setup(), before = editor.getSnapshot(); let captured;
|
const editor = setup(), before = editor.getSnapshot(); let captured;
|
||||||
const result = editor.transaction(async tx => {
|
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/);
|
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', () => {
|
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 unknown = { ...node('future', 'root', 'future/audio'), dataVersion: 99, data: { novel: ['keep', { every: 'field' }] } };
|
||||||
const editor = setup([node('a'), unknown]);
|
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');
|
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', () => {
|
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/);
|
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 })] })]);
|
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);
|
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', () => {
|
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 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');
|
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);
|
assert.equal(original.dataVersion, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 分别构造重复component与不同feature抢同能力,确认配置拒绝;成功装配表也不暴露set。
|
||||||
test('distinct features cannot install the same capability or overwrite an existing component',()=>{
|
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 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]});
|
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);
|
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',()=>{
|
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);
|
const original={...node('a'),data:{title:'Original',links:['b'],nested:{retain:true}}},saved=structuredClone(original);
|
||||||
for(const migrations of[
|
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));
|
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',()=>{
|
test('reentrant mutation entry points reject without publishing document, history, runtime, indexes or operations',()=>{
|
||||||
for(const attempt of[
|
for(const attempt of[
|
||||||
editor=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'nested'}}),
|
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', () => {
|
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 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' } }];
|
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);
|
assert.strictEqual(editor.getSnapshot(), before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 快照集合无set,订阅者抛错也不能回滚已发布的合法提交;观察通知与提交成功边界分开。
|
||||||
test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => {
|
test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => {
|
||||||
const editor = setup();
|
const editor = setup();
|
||||||
assert.equal(editor.getSnapshot().document.elements.set, undefined);
|
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');
|
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 () => {
|
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 { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
||||||
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
|
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(); sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远端几何覆盖导致预览取消;之后detach再次取消手势,文档保持远端已提交位置。
|
||||||
test('geometry conflict and view detach cancel only that gesture, without deleting model content', async () => {
|
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 { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
|
||||||
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
|
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();
|
sessions.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// prepareIntent伪造操作ID并清changes,必须在发布前拒绝,不允许传输准备重写业务事实。
|
||||||
test('transport preparation cannot rewrite a committed document change or its identity',()=>{
|
test('transport preparation cannot rewrite a committed document change or its identity',()=>{
|
||||||
const editor=setup(undefined,{prepareIntent:intent=>({...intent,id:'another-operation',changes:[]})});
|
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'}});
|
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);
|
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()=>{
|
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 {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||||
const editor=setup();
|
const editor=setup();
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 批量创建草稿的不可变前缀与工作量测试。以1000条底表检查逐条stage不重复复制整表,旧前缀不随追加变化。
|
||||||
|
* 策略、schema和runtime拒绝仍必须整次事务不发布;计数/替换迭代器只测本路径,不声称所有创建成本固定。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createEditor,createRegistry,builtinComponents,isFactoryDocument} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createEditor,createRegistry,builtinComponents,isFactoryDocument} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {stageCreation,materializeCreations} from '../../src/lib/canvas-core/creation-draft.ts';
|
import {stageCreation,materializeCreations} from '../../src/lib/canvas-core/creation-draft.ts';
|
||||||
|
/** 构造1000个既有节点的文档;每个用例新建,registry可共享,只读前缀用于后续对照。 */
|
||||||
const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:''}}),document=()=>createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node('old-'+i))),registry=createRegistry(builtinComponents());
|
const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:''}}),document=()=>createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node('old-'+i))),registry=createRegistry(builtinComponents());
|
||||||
|
// 连续追加130条跨过窗口边界,保存第1/64/65个前缀;检查顺序、只读集合、forEach this与materialize结果及重复ID拒绝。
|
||||||
test('creation drafts avoid reenumerating the base per item, preserve held prefixes and fold bounded append windows in order',()=>{
|
test('creation drafts avoid reenumerating the base per item, preserve held prefixes and fold bounded append windows in order',()=>{
|
||||||
const base=document();let reads=0;const elements={...base.elements,[Symbol.iterator](){reads++;return base.elements[Symbol.iterator]();}},source={...base,elements};let draft=source;const held=[];
|
const base=document();let reads=0;const elements={...base.elements,[Symbol.iterator](){reads++;return base.elements[Symbol.iterator]();}},source={...base,elements};let draft=source;const held=[];
|
||||||
for(let i=0;i<130;i++){draft=stageCreation(draft,{kind:'element',id:'new-'+i,before:null,after:node('new-'+i)});if([0,63,64].includes(i))held.push(draft);if(i===31)assert.equal(reads,0);}
|
for(let i=0;i<130;i++){draft=stageCreation(draft,{kind:'element',id:'new-'+i,before:null,after:node('new-'+i)});if([0,63,64].includes(i))held.push(draft);if(i===31)assert.equal(reads,0);}
|
||||||
assert.ok(reads<=1);assert.deepEqual(held.map(d=>d.elements.size),[1001,1064,1065]);assert.equal(held[0].elements.has('new-1'),false);assert.equal(draft.elements.get('old-10').id,'old-10');assert.equal(draft.elements.get('new-129').id,'new-129');assert.equal(draft.elements.set,undefined);const ids=[...draft.elements.keys()];assert.deepEqual(ids.slice(-130),Array.from({length:130},(_,i)=>'new-'+i));assert.equal([...draft.elements.values()].length,1130);const visits=[];draft.elements.forEach(function(value,key,map){assert.equal(this.tag,true);assert.strictEqual(map,draft.elements);visits.push(key);},{tag:true});assert.deepEqual(visits,ids);const materialized=materializeCreations(draft);assert.ok(isFactoryDocument(materialized));assert.equal(materialized.elements.size,1130);assert.equal(base.elements.size,1000);assert.throws(()=>stageCreation(draft,{kind:'element',id:'new-0',before:null,after:node('new-0')}),/duplicate_identity/);
|
assert.ok(reads<=1);assert.deepEqual(held.map(d=>d.elements.size),[1001,1064,1065]);assert.equal(held[0].elements.has('new-1'),false);assert.equal(draft.elements.get('old-10').id,'old-10');assert.equal(draft.elements.get('new-129').id,'new-129');assert.equal(draft.elements.set,undefined);const ids=[...draft.elements.keys()];assert.deepEqual(ids.slice(-130),Array.from({length:130},(_,i)=>'new-'+i));assert.equal([...draft.elements.values()].length,1130);const visits=[];draft.elements.forEach(function(value,key,map){assert.equal(this.tag,true);assert.strictEqual(map,draft.elements);visits.push(key);},{tag:true});assert.deepEqual(visits,ids);const materialized=materializeCreations(draft);assert.ok(isFactoryDocument(materialized));assert.equal(materialized.elements.size,1130);assert.equal(base.elements.size,1000);assert.throws(()=>stageCreation(draft,{kind:'element',id:'new-0',before:null,after:node('new-0')}),/duplicate_identity/);
|
||||||
});
|
});
|
||||||
|
// 策略按命令顺序看到1000/1001/1002前缀,持有前缀也固定;创建与中间编辑一起只发布一次、产生一个操作/历史单元。
|
||||||
test('ordered command policies see stable prior prefixes; one owned publication has one history unit and round-trips undo/redo',()=>{
|
test('ordered command policies see stable prior prefixes; one owned publication has one history unit and round-trips undo/redo',()=>{
|
||||||
const held=[],seen=[];let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy(command,doc){held.push(doc);seen.push([command.type,doc.elements.size,doc.canvases.size]);return{enabled:true};}});let publications=0;editor.subscribe(()=>publications++);const before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});const prefix=tx.getDocument();tx.dispatch({type:'element.create',element:node('b')});assert.equal(prefix.elements.has('b'),false);tx.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'edited'}});tx.dispatch({type:'element.create',element:node('c')});});assert.equal(result.status,'applied');assert.deepEqual(seen.map(row=>row[1]),[1000,1001,1002,1002]);assert.deepEqual(held.map(d=>d.elements.size),[1000,1001,1002,1002]);assert.equal(publications,1);const after=editor.getSnapshot();assert.ok(isFactoryDocument(after.document));assert.equal(after.history.undo.length,1);assert.equal(after.operations.size,1);assert.equal(after.document.elements.get('a').data.title,'edited');assert.equal(before.document.elements.size,1000);assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.size,1000);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'edited');
|
const held=[],seen=[];let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy(command,doc){held.push(doc);seen.push([command.type,doc.elements.size,doc.canvases.size]);return{enabled:true};}});let publications=0;editor.subscribe(()=>publications++);const before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});const prefix=tx.getDocument();tx.dispatch({type:'element.create',element:node('b')});assert.equal(prefix.elements.has('b'),false);tx.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'edited'}});tx.dispatch({type:'element.create',element:node('c')});});assert.equal(result.status,'applied');assert.deepEqual(seen.map(row=>row[1]),[1000,1001,1002,1002]);assert.deepEqual(held.map(d=>d.elements.size),[1000,1001,1002,1002]);assert.equal(publications,1);const after=editor.getSnapshot();assert.ok(isFactoryDocument(after.document));assert.equal(after.history.undo.length,1);assert.equal(after.operations.size,1);assert.equal(after.document.elements.get('a').data.title,'edited');assert.equal(before.document.elements.size,1000);assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.size,1000);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'edited');
|
||||||
});
|
});
|
||||||
|
// 四种失败分别发生在创建流水线不同位置,结果均rejected且根、history、operations保持原状。
|
||||||
test('policy, schema, duplicate and runtime failures never publish staged creations',()=>{
|
test('policy, schema, duplicate and runtime failures never publish staged creations',()=>{
|
||||||
for(const failure of ['policy','schema','duplicate','runtime']){let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy:(command)=>failure==='policy'&&command.type==='element.create'&&command.element.id==='b'?{enabled:false,code:'denied',targets:['b']}:{enabled:true},reduceRuntime(){if(failure==='runtime')throw Error('runtime_failed');}}),before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});tx.dispatch({type:'element.create',element:failure==='duplicate'?node('a'):failure==='schema'?{...node('b'),geometry:{...node('b').geometry,width:-1}}:node('b')});});assert.equal(result.status,'rejected',failure);assert.strictEqual(editor.getSnapshot(),before);assert.equal(editor.getSnapshot().history.undo.length,0);assert.equal(editor.getSnapshot().operations.size,0);}
|
for(const failure of ['policy','schema','duplicate','runtime']){let n=0;const editor=createEditor({document:document(),registry,createId:()=>`id-${++n}`,policy:(command)=>failure==='policy'&&command.type==='element.create'&&command.element.id==='b'?{enabled:false,code:'denied',targets:['b']}:{enabled:true},reduceRuntime(){if(failure==='runtime')throw Error('runtime_failed');}}),before=editor.getSnapshot();const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('a')});tx.dispatch({type:'element.create',element:failure==='duplicate'?node('a'):failure==='schema'?{...node('b'),geometry:{...node('b').geometry,width:-1}}:node('b')});});assert.equal(result.status,'rejected',failure);assert.strictEqual(editor.getSnapshot(),before);assert.equal(editor.getSnapshot().history.undo.length,0);assert.equal(editor.getSnapshot().operations.size,0);}
|
||||||
});
|
});
|
||||||
|
// 先创建入口,再canvas,再内部节点;旧前缀不见后来结构,undo移除整组,redo恢复入口关系。
|
||||||
test('canvas and element creation drafts compose a tree while held canvas prefixes and base snapshots remain fixed',()=>{
|
test('canvas and element creation drafts compose a tree while held canvas prefixes and base snapshots remain fixed',()=>{
|
||||||
const editor=createEditor({document:document(),registry});let first,second;const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}});first=tx.getDocument();tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'root',entryElementId:'entry'}}});second=tx.getDocument();tx.dispatch({type:'element.create',element:{...node('nested'),ownerCanvasId:'child'}});assert.equal(first.canvases.has('child'),false);assert.equal(second.elements.has('nested'),false);assert.equal(tx.getDocument().canvases.get('child').parent.entryElementId,'entry');});assert.equal(result.status,'applied');assert.ok(isFactoryDocument(editor.getSnapshot().document));assert.equal(editor.getSnapshot().document.elements.get('nested').ownerCanvasId,'child');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.has('child'),false);assert.equal(editor.getSnapshot().document.elements.has('entry'),false);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.get('child').parent.entryElementId,'entry');
|
const editor=createEditor({document:document(),registry});let first,second;const result=editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}});first=tx.getDocument();tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'root',entryElementId:'entry'}}});second=tx.getDocument();tx.dispatch({type:'element.create',element:{...node('nested'),ownerCanvasId:'child'}});assert.equal(first.canvases.has('child'),false);assert.equal(second.elements.has('nested'),false);assert.equal(tx.getDocument().canvases.get('child').parent.entryElementId,'entry');});assert.equal(result.status,'applied');assert.ok(isFactoryDocument(editor.getSnapshot().document));assert.equal(editor.getSnapshot().document.elements.get('nested').ownerCanvasId,'child');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.has('child'),false);assert.equal(editor.getSnapshot().document.elements.has('entry'),false);assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.canvases.get('child').parent.entryElementId,'entry');
|
||||||
});
|
});
|
||||||
|
// 事务内暂替Map迭代器,禁止大表遍历;32次创建仍获准且单一历史,finally恢复全局原型方法。
|
||||||
test('editor creation staging does not enumerate an existing thousand-element table for each command',()=>{
|
test('editor creation staging does not enumerate an existing thousand-element table for each command',()=>{
|
||||||
const editor=createEditor({document:document(),registry});const iterator=Map.prototype[Symbol.iterator];let admissions=0;const result=editor.transaction(tx=>{try{Map.prototype[Symbol.iterator]=function(){if(this.size>=1000)throw Error('full table copied during creation staging');return iterator.call(this);};for(let i=0;i<32;i++){assert.equal(tx.dispatch({type:'element.create',element:node('batch-'+i)}).enabled,true);admissions++;}}finally{Map.prototype[Symbol.iterator]=iterator;}});assert.equal(result.status,'applied');assert.equal(admissions,32);assert.equal(editor.getSnapshot().document.elements.size,1032);assert.equal(editor.getSnapshot().history.undo.length,1);
|
const editor=createEditor({document:document(),registry});const iterator=Map.prototype[Symbol.iterator];let admissions=0;const result=editor.transaction(tx=>{try{Map.prototype[Symbol.iterator]=function(){if(this.size>=1000)throw Error('full table copied during creation staging');return iterator.call(this);};for(let i=0;i<32;i++){assert.equal(tx.dispatch({type:'element.create',element:node('batch-'+i)}).enabled,true);admissions++;}}finally{Map.prototype[Symbol.iterator]=iterator;}});assert.equal(result.status,'applied');assert.equal(admissions,32);assert.equal(editor.getSnapshot().document.elements.size,1032);assert.equal(editor.getSnapshot().history.undo.length,1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 异步获知图片默认尺寸后修正创建历史的规划测试。plan只生成候选document/history,不能修改已发intent或凭空添加撤销单元。
|
||||||
|
* 区分用户显式resize、单位换算、移动和redo分支,检查整个undo/redo路径仍可恢复,不以当前尺寸碰巧相同判定未改过。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDocument, createEditor, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
import { createDocument, createEditor, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -6,7 +10,9 @@ import { applyChanges, invert } from '../../src/lib/canvas-core/document.ts';
|
|||||||
import { describeHistoryTransition, applyHistoryTransition } from '../../src/lib/canvas-core/history-transition.ts';
|
import { describeHistoryTransition, applyHistoryTransition } from '../../src/lib/canvas-core/history-transition.ts';
|
||||||
|
|
||||||
const registry = createRegistry(builtinComponents());
|
const registry = createRegistry(builtinComponents());
|
||||||
|
/** 固定image第一生命周期,删除恢复后这个异步任务目标应失效。 */
|
||||||
const target = { id: 'image', incarnation: 1 };
|
const target = { id: 'image', incarnation: 1 };
|
||||||
|
/** 先创建260×200图片并记录创建history entry,propose统一尝试改默认值为260×130,允许覆盖editor依赖。 */
|
||||||
function setup(options = {}) {
|
function setup(options = {}) {
|
||||||
let id = 0;
|
let id = 0;
|
||||||
const editor = createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], []), registry, createId: () => `id-${++id}`, ...options });
|
const editor = createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], []), registry, createId: () => `id-${++id}`, ...options });
|
||||||
@@ -15,6 +21,7 @@ function setup(options = {}) {
|
|||||||
const entry = editor.getSnapshot().history.undo[0];
|
const entry = editor.getSnapshot().history.undo[0];
|
||||||
return { editor, entry, propose() { const { document, history } = editor.getSnapshot(); return planCreationSize(document, history, registry, entry, target, { width: 260, height: 130 }); } };
|
return { editor, entry, propose() { const { document, history } = editor.getSnapshot(); return planCreationSize(document, history, registry, entry, target, { width: 260, height: 130 }); } };
|
||||||
}
|
}
|
||||||
|
/** 从plan当前状态逆序撤销到空,再按undo顺序重做,对照最终图片;只适用于不含额外初始元素的调用。 */
|
||||||
function traverse(plan) {
|
function traverse(plan) {
|
||||||
let document = plan.document;
|
let document = plan.document;
|
||||||
for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
|
for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
|
||||||
@@ -23,6 +30,7 @@ function traverse(plan) {
|
|||||||
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
// 创建后移位/旋转/改名,默认尺寸修正保留这些编辑与栈顺序;原editor根和序列化intent均不变。
|
||||||
test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => {
|
test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => {
|
||||||
const { editor, entry, propose } = setup();
|
const { editor, entry, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { x: 80, rotation: 25 } });
|
editor.dispatch({ type: 'element.geometry', target, patch: { x: 80, rotation: 25 } });
|
||||||
@@ -37,6 +45,7 @@ test('default size enriches the creation unit across move, rotation and title ed
|
|||||||
assert.deepEqual([...before.operations.values()].map(value => JSON.stringify(value)), intents);
|
assert.deepEqual([...before.operations.values()].map(value => JSON.stringify(value)), intents);
|
||||||
traverse(plan);
|
traverse(plan);
|
||||||
});
|
});
|
||||||
|
// 位置变化撤销后仍在redo,规划保留redo且当前x不提前跳到未来值;实际重做再得到90与新默认高度。
|
||||||
test('an unrelated redo stack is retained and rebased without becoming the working document', () => {
|
test('an unrelated redo stack is retained and rebased without becoming the working document', () => {
|
||||||
const { editor, propose } = setup();
|
const { editor, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { x: 90 } }); editor.history.undo();
|
editor.dispatch({ type: 'element.geometry', target, patch: { x: 90 } }); editor.history.undo();
|
||||||
@@ -48,6 +57,7 @@ test('an unrelated redo stack is retained and rebased without becoming the worki
|
|||||||
assert.equal(document.elements.get('image').geometry.x, 90);
|
assert.equal(document.elements.get('image').geometry.x, 90);
|
||||||
assert.equal(document.elements.get('image').geometry.height, 130);
|
assert.equal(document.elements.get('image').geometry.height, 130);
|
||||||
});
|
});
|
||||||
|
// resize已撤销时允许修默认基线;redo仍用用户300高度,逆向回到修正后的130。
|
||||||
test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => {
|
test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => {
|
||||||
const { editor, propose } = setup();
|
const { editor, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { width: 400, height: 300 } }); editor.history.undo();
|
editor.dispatch({ type: 'element.geometry', target, patch: { width: 400, height: 300 } }); editor.history.undo();
|
||||||
@@ -56,6 +66,7 @@ test('a future explicit resize keeps the chosen dimensions, with a corrected und
|
|||||||
assert.equal(resized.elements.get('image').geometry.height, 300);
|
assert.equal(resized.elements.get('image').geometry.height, 300);
|
||||||
assert.equal(applyChanges(resized, invert(resize.changes)).elements.get('image').geometry.height, 130);
|
assert.equal(applyChanges(resized, invert(resize.changes)).elements.get('image').geometry.height, 130);
|
||||||
});
|
});
|
||||||
|
// 用户先改高400再改回200,仍属于显式调整,不得以最终值相同覆盖其意图。
|
||||||
test('applied resize rejects even after a later resize returns to the initial dimensions', () => {
|
test('applied resize rejects even after a later resize returns to the initial dimensions', () => {
|
||||||
const { editor, propose } = setup();
|
const { editor, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { height: 400 } });
|
editor.dispatch({ type: 'element.geometry', target, patch: { height: 400 } });
|
||||||
@@ -63,15 +74,18 @@ test('applied resize rejects even after a later resize returns to the initial di
|
|||||||
const before = editor.getSnapshot();
|
const before = editor.getSnapshot();
|
||||||
assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before);
|
assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before);
|
||||||
});
|
});
|
||||||
|
// 创建已撤销,原图片不存在,异步默认尺寸任务以target_changed拒绝。
|
||||||
test('an undone creation cannot be enriched', () => {
|
test('an undone creation cannot be enriched', () => {
|
||||||
const { editor, propose } = setup(); editor.history.undo();
|
const { editor, propose } = setup(); editor.history.undo();
|
||||||
assert.throws(propose, { code: 'creation_size_target_changed' });
|
assert.throws(propose, { code: 'creation_size_target_changed' });
|
||||||
});
|
});
|
||||||
|
// 删除再恢复使生命周期变化,即使ID相同,旧创建尺寸任务也不能修改新对象。
|
||||||
test('a restored element has a new incarnation and rejects the old asynchronous task', () => {
|
test('a restored element has a new incarnation and rejects the old asynchronous task', () => {
|
||||||
const { editor, propose } = setup(); editor.dispatch({ type: 'element.remove', target }); editor.history.undo();
|
const { editor, propose } = setup(); editor.dispatch({ type: 'element.remove', target }); editor.history.undo();
|
||||||
// Undo of removal creates a new incarnation: an old asynchronous task is stale.
|
// Undo of removal creates a new incarnation: an old asynchronous task is stale.
|
||||||
assert.throws(propose, { code: 'creation_size_target_changed' });
|
assert.throws(propose, { code: 'creation_size_target_changed' });
|
||||||
});
|
});
|
||||||
|
// 用已有historyTransition格式描述plan,JSON往返后正反回放仍精确相等,entries不暴露set。
|
||||||
test('the complete history proposal is reversible using the existing transition proof format', () => {
|
test('the complete history proposal is reversible using the existing transition proof format', () => {
|
||||||
const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } });
|
const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } });
|
||||||
const before = editor.getSnapshot(), plan = propose();
|
const before = editor.getSnapshot(), plan = propose();
|
||||||
@@ -82,6 +96,7 @@ test('the complete history proposal is reversible using the existing transition
|
|||||||
assert.deepEqual([...restored.entries], [...before.history.entries]);
|
assert.deepEqual([...restored.entries], [...before.history.entries]);
|
||||||
assert.equal(plan.history.entries.set, undefined);
|
assert.equal(plan.history.entries.set, undefined);
|
||||||
});
|
});
|
||||||
|
// 允许raw changes构造单位×2与几何同比变化,修正尺寸按单位换算而非误判成用户resize,保留最新位置并遍历历史。
|
||||||
test('a logical unit conversion preserves size, latest position and the full undo path', () => {
|
test('a logical unit conversion preserves size, latest position and the full undo path', () => {
|
||||||
const { editor, propose } = setup({ allowRawChanges: true });
|
const { editor, propose } = setup({ allowRawChanges: true });
|
||||||
const before = editor.getSnapshot().document.elements.get('image');
|
const before = editor.getSnapshot().document.elements.get('image');
|
||||||
@@ -96,6 +111,7 @@ test('a logical unit conversion preserves size, latest position and the full und
|
|||||||
assert.equal(plan.document.elements.get('image').geometry.x, 45);
|
assert.equal(plan.document.elements.get('image').geometry.x, 45);
|
||||||
traverse(plan);
|
traverse(plan);
|
||||||
});
|
});
|
||||||
|
// 图片移入contentScale=2子图后仍保留owner/位置,默认高度换到局部65;undo只移图片不删初始frame,再重做一致。
|
||||||
test('a real move into a scaled child canvas is retained through enrichment and undo', () => {
|
test('a real move into a scaled child canvas is retained through enrichment and undo', () => {
|
||||||
const frame = { id: 'frame', incarnation: 1, ownerCanvasId: 'root', componentId: 'frame', dataVersion: 1, structure: 'node', data: { title: 'Frame', text: '', presentation: { contentScale: 2 } }, geometry: { x: 0, y: 0, width: 600, height: 400, rotation: 0 } };
|
const frame = { id: 'frame', incarnation: 1, ownerCanvasId: 'root', componentId: 'frame', dataVersion: 1, structure: 'node', data: { title: 'Frame', text: '', presentation: { contentScale: 2 } }, geometry: { x: 0, y: 0, width: 600, height: 400, rotation: 0 } };
|
||||||
const { editor, propose } = setup({ document: createDocument('p', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [frame]) });
|
const { editor, propose } = setup({ document: createDocument('p', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [frame]) });
|
||||||
@@ -110,11 +126,13 @@ test('a real move into a scaled child canvas is retained through enrichment and
|
|||||||
for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes);
|
for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes);
|
||||||
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
||||||
});
|
});
|
||||||
|
// historyLimit=1淘汰创建条目后不能修补,也不能为了异步结果补造历史单元。
|
||||||
test('evicted creation history is rejected without synthesizing a new undo entry', () => {
|
test('evicted creation history is rejected without synthesizing a new undo entry', () => {
|
||||||
const { editor, propose } = setup({ historyLimit: 1 });
|
const { editor, propose } = setup({ historyLimit: 1 });
|
||||||
editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } });
|
editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } });
|
||||||
assert.throws(propose, { code: 'creation_size_target_changed' });
|
assert.throws(propose, { code: 'creation_size_target_changed' });
|
||||||
});
|
});
|
||||||
|
// resize与移动都撤销后,按redo正确次序重放保留用户300高和x=75,再反向回到plan状态。
|
||||||
test('a future resize followed by movement retains the user size through both redo entries', () => {
|
test('a future resize followed by movement retains the user size through both redo entries', () => {
|
||||||
const { editor, propose } = setup();
|
const { editor, propose } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
|
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
|
||||||
@@ -127,6 +145,7 @@ test('a future resize followed by movement retains the user size through both re
|
|||||||
for (const id of plan.history.redo) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
|
for (const id of plan.history.redo) document = applyChanges(document, invert(plan.history.entries.get(id).changes));
|
||||||
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
|
||||||
});
|
});
|
||||||
|
// Infinity尺寸拒绝;远端内容已改变使历史基准过期,同样拒绝且不改editor根。
|
||||||
test('invalid sizes and stale document baselines are rejected without mutation', () => {
|
test('invalid sizes and stale document baselines are rejected without mutation', () => {
|
||||||
const { editor, entry } = setup(); const root = editor.getSnapshot();
|
const { editor, entry } = setup(); const root = editor.getSnapshot();
|
||||||
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: Infinity, height: 1 }), { code: 'invalid_creation_size' });
|
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: Infinity, height: 1 }), { code: 'invalid_creation_size' });
|
||||||
@@ -135,10 +154,12 @@ test('invalid sizes and stale document baselines are rejected without mutation',
|
|||||||
assert.throws(() => planCreationSize(stale.document, stale.history, registry, entry, target, { width: 260, height: 130 }), { code: 'element_conflict' });
|
assert.throws(() => planCreationSize(stale.document, stale.history, registry, entry, target, { width: 260, height: 130 }), { code: 'element_conflict' });
|
||||||
assert.strictEqual(editor.getSnapshot(), stale);
|
assert.strictEqual(editor.getSnapshot(), stale);
|
||||||
});
|
});
|
||||||
|
// 0.5高不满足组件尺寸约束,规划必须拒绝,不能因默认值修正绕过普通resize限制。
|
||||||
test('component constraints cannot be bypassed by a default size proposal', () => {
|
test('component constraints cannot be bypassed by a default size proposal', () => {
|
||||||
const { editor, entry } = setup(); const root = editor.getSnapshot();
|
const { editor, entry } = setup(); const root = editor.getSnapshot();
|
||||||
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: 260, height: 0.5 }), { code: 'creation_size_constraint' });
|
assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: 260, height: 0.5 }), { code: 'creation_size_constraint' });
|
||||||
});
|
});
|
||||||
|
// 默认宽改520、高130,redo只改高300时应保留新默认宽,避免拿旧整份geometry覆盖未编辑轴。
|
||||||
test('a redo edit to one size axis preserves the corrected default on the other axis', () => {
|
test('a redo edit to one size axis preserves the corrected default on the other axis', () => {
|
||||||
const { editor, entry } = setup();
|
const { editor, entry } = setup();
|
||||||
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
|
editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } });
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 浏览器 files 表与共享 depot 的联动场景:确认上传后项目文件变为指针,物理字节按摘要共享。
|
||||||
|
* 覆盖 MIME 恢复、资源级回收、事务回滚、DB v3 到 v5 懒升级及旧状态提交/新接纳之间的回收竞态。
|
||||||
|
* 这些导出函数由浏览器 runner 调用;Node 单元测试本身不能证明真实 IDB 场景已执行。
|
||||||
|
*/
|
||||||
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
||||||
|
// 在独立数据库内直接探查 files/asset-bytes;以相同字节、不同原始 MIME 的两个资源检查指针语义。
|
||||||
export async function depotFilesCase(assert){
|
export async function depotFilesCase(assert){
|
||||||
const name=`depot-files-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},authority='https://assets.test',a=new Blob(['shared bytes'],{type:'image/jpeg'}),b=new Blob(['shared bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await a.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),storage=createBrowserStorage({name});let p,q,raw;
|
const name=`depot-files-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},authority='https://assets.test',a=new Blob(['shared bytes'],{type:'image/jpeg'}),b=new Blob(['shared bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await a.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),storage=createBrowserStorage({name});let p,q,raw;
|
||||||
|
// 构造服务端资源、上传状态、最小 image 和 manifest;stored=false 用于模拟尚未确认的上传。
|
||||||
const ref=id=>({version:1,authority,digest,asset:{id,url:`${authority}/${id}`,width:2,height:1,bytes:a.size,mime_type:'image/png'}}),resource=(id,mimeType,stored=true)=>({id,operationId:`upload-${id}`,digest,byteLength:a.size,mimeType,...(stored?{stored:ref(id).asset}:{})}),image=(n,resources)=>({formatVersion:1,rootRevision:n,localRevision:n,payload:{runtime:{resources}}}),cut=(pins=[],writes=[])=>({version:1,authority,complete:true,pins,writes});
|
const ref=id=>({version:1,authority,digest,asset:{id,url:`${authority}/${id}`,width:2,height:1,bytes:a.size,mime_type:'image/png'}}),resource=(id,mimeType,stored=true)=>({id,operationId:`upload-${id}`,digest,byteLength:a.size,mimeType,...(stored?{stored:ref(id).asset}:{})}),image=(n,resources)=>({formatVersion:1,rootRevision:n,localRevision:n,payload:{runtime:{resources}}}),cut=(pins=[],writes=[])=>({version:1,authority,complete:true,pins,writes});
|
||||||
|
// 项目文件主键仍含项目作用域,两个 ResourceId 可引用同一摘要;pin 来源分别记录元素身份。
|
||||||
const key=JSON.stringify(['a','w','p']),rows=[['one',resource('one',a.type)],['two',resource('two',b.type)]],pins=[['one',['element:one']],['two',['element:two']]],writes=[{ref:ref('one'),bytes:a},{ref:ref('two'),bytes:b}];
|
const key=JSON.stringify(['a','w','p']),rows=[['one',resource('one',a.type)],['two',resource('two',b.type)]],pins=[['one',['element:one']],['two',['element:two']]],writes=[{ref:ref('one'),bytes:a},{ref:ref('two'),bytes:b}];
|
||||||
|
// 另开原始 IDB 连接,检查数据库版本及底层记录,不借助待验证的 storage.readFile 推断行类型。
|
||||||
const openRaw=()=>new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
const openRaw=()=>new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||||
|
// 读取任意 object store 的单行,用于断言 Blob 到指针的迁移及后续故障注入。
|
||||||
const get=(store,key)=>new Promise((resolve,reject)=>{const tx=raw.transaction(store),request=tx.objectStore(store).get(key);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
const get=(store,key)=>new Promise((resolve,reject)=>{const tx=raw.transaction(store),request=tx.objectStore(store).get(key);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||||
|
// 统计物理行,证明按 digest 去重及最后引用清除后的实际字节删除。
|
||||||
const count=store=>new Promise((resolve,reject)=>{const tx=raw.transaction(store),request=tx.objectStore(store).count();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
const count=store=>new Promise((resolve,reject)=>{const tx=raw.transaction(store),request=tx.objectStore(store).count();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||||
|
// 直接以事务覆盖底层行以注入未来 fileVersion 或错误摘要;等待事务完成,确保后续读取看到故障。
|
||||||
const put=(store,key,value)=>new Promise((resolve,reject)=>{const tx=raw.transaction(store,'readwrite');tx.objectStore(store).put(value,key);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});
|
const put=(store,key,value)=>new Promise((resolve,reject)=>{const tx=raw.transaction(store,'readwrite');tx.objectStore(store).put(value,key);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});
|
||||||
|
// 断言指定异步错误码;预期失败却返回成功时也抛错,避免 try/catch 吞掉问题。
|
||||||
const rejects=async(fn,code)=>{try{await fn();}catch(error){assert(error.code===code,`expected ${code}, got ${error.code}`);return;}throw Error(`expected ${code}`);};
|
const rejects=async(fn,code)=>{try{await fn();}catch(error){assert(error.code===code,`expected ${code}, got ${error.code}`);return;}throw Error(`expected ${code}`);};
|
||||||
|
// 固定 authority、宽限期和扫描上限,仅改变当前时间来分别触发标记与 purge。
|
||||||
const collect=now=>storage.assetDepot.collect(p,{authority,now,retentionMs:10,limit:128});
|
const collect=now=>storage.assetDepot.collect(p,{authority,now,retentionMs:10,limit:128});
|
||||||
try{
|
try{
|
||||||
p=await storage.claim(scope);q=await storage.claim({...scope,projectId:'q'});raw=await openRaw();assert(raw.version===5,'pointer storage did not upgrade to DB v5');
|
p=await storage.claim(scope);q=await storage.claim({...scope,projectId:'q'});raw=await openRaw();assert(raw.version===5,'pointer storage did not upgrade to DB v5');
|
||||||
@@ -15,37 +29,53 @@ export async function depotFilesCase(assert){
|
|||||||
assert(await get('files',[key,'one']) instanceof Blob,'unconfirmed upload lost its original bytes');
|
assert(await get('files',[key,'one']) instanceof Blob,'unconfirmed upload lost its original bytes');
|
||||||
// The first pointer is converted before the second row fails. Everything must roll back.
|
// The first pointer is converted before the second row fails. Everything must roll back.
|
||||||
await rejects(()=>storage.commit(p,{image:image(1,[rows[0],['two',resource('two','image/webp')]]),depot:cut(pins,writes)}),'shared_asset_bytes_changed');assert(await get('files',[key,'one']) instanceof Blob&&await count('asset-bytes')===0&&(await storage.recover(p)).rootRevision===0,'failed promotion partially converted files/bytes/Root');
|
await rejects(()=>storage.commit(p,{image:image(1,[rows[0],['two',resource('two','image/webp')]]),depot:cut(pins,writes)}),'shared_asset_bytes_changed');assert(await get('files',[key,'one']) instanceof Blob&&await count('asset-bytes')===0&&(await storage.recover(p)).rootRevision===0,'failed promotion partially converted files/bytes/Root');
|
||||||
|
// 所有字段合法时将两个项目 Blob 提升为同摘要字节及独立指针,随后仍按各自原始 MIME 读回。
|
||||||
await storage.commit(p,{image:image(1,rows),depot:cut(pins,writes)});assert(await count('asset-bytes')===1,'same digest occupied multiple shared byte entries');for(const id of ['one','two']){const local=await get('files',[key,id]);assert(!(local instanceof Blob)&&local.fileVersion===1&&local.digest===digest,'stored upload still retained a project-local Blob');}
|
await storage.commit(p,{image:image(1,rows),depot:cut(pins,writes)});assert(await count('asset-bytes')===1,'same digest occupied multiple shared byte entries');for(const id of ['one','two']){const local=await get('files',[key,id]);assert(!(local instanceof Blob)&&local.fileVersion===1&&local.digest===digest,'stored upload still retained a project-local Blob');}
|
||||||
assert((await storage.readFile(p,'one')).type===a.type&&(await storage.readFile(p,'two')).type===b.type,'pointer read changed the original File MIME');assert(await(await storage.readFile(p,'one')).text()==='shared bytes','pointer did not resolve original bytes');
|
assert((await storage.readFile(p,'one')).type===a.type&&(await storage.readFile(p,'two')).type===b.type,'pointer read changed the original File MIME');assert(await(await storage.readFile(p,'one')).text()==='shared bytes','pointer did not resolve original bytes');
|
||||||
|
// scanFiles 返回逻辑文件字节数/MIME;实现不应暴露指针行的物理存储形态。
|
||||||
const inventory=await storage.scanFiles(p,{expectedRootRevision:1,limit:128});assert(inventory.files.length===2&&inventory.files[0].mimeType===a.type&&inventory.files.every(row=>row.byteLength===a.size),'inventory did not expose logical file metadata');
|
const inventory=await storage.scanFiles(p,{expectedRootRevision:1,limit:128});assert(inventory.files.length===2&&inventory.files[0].mimeType===a.type&&inventory.files.every(row=>row.byteLength===a.size),'inventory did not expose logical file metadata');
|
||||||
|
// q 的历史 pin 保护 one;p 清空 manifest 后先回收 two,不能因为同摘要仍在就让已 purge 的 two 继续可读。
|
||||||
await storage.commit(q,{image:image(0,[]),depot:cut([['one',['history:q']]])});await storage.commit(p,{image:image(2,rows),depot:cut()});await collect(0);await collect(10);assert(await count('asset-bytes')===1,'purging one ResourceId deleted another ResourceId bytes');assert(await storage.readFile(p,'two')===undefined,'purged identity resolved through another live digest owner');assert((await storage.scanFiles(p,{expectedRootRevision:2,limit:128})).files.length===1,'inventory advertised an evicted pointer as readable bytes');
|
await storage.commit(q,{image:image(0,[]),depot:cut([['one',['history:q']]])});await storage.commit(p,{image:image(2,rows),depot:cut()});await collect(0);await collect(10);assert(await count('asset-bytes')===1,'purging one ResourceId deleted another ResourceId bytes');assert(await storage.readFile(p,'two')===undefined,'purged identity resolved through another live digest owner');assert((await storage.scanFiles(p,{expectedRootRevision:2,limit:128})).files.length===1,'inventory advertised an evicted pointer as readable bytes');
|
||||||
|
// 最后的 q pin 清除后,再经标记及宽限期才能删除共享摘要字节。
|
||||||
await storage.commit(q,{image:image(1,[]),depot:cut()});await collect(10);await collect(20);assert(await count('asset-bytes')===0&&await storage.readFile(p,'one')===undefined,'last unreferenced digest was retained');
|
await storage.commit(q,{image:image(1,[]),depot:cut()});await collect(10);await collect(20);assert(await count('asset-bytes')===0&&await storage.readFile(p,'one')===undefined,'last unreferenced digest was retained');
|
||||||
|
// 授权补回 one 只恢复它的指针;two 即使共用摘要也不能被顺手复活。
|
||||||
await storage.commit(p,{image:image(3,rows),depot:cut([['one',['element:one']]],[writes[0]])});assert(await count('asset-bytes')===1&&await(await storage.readFile(p,'one')).text()==='shared bytes','same identity could not rehydrate a file pointer');assert(await storage.readFile(p,'two')===undefined,'rehydrating one identity revived another purged identity');
|
await storage.commit(p,{image:image(3,rows),depot:cut([['one',['element:one']]],[writes[0]])});assert(await count('asset-bytes')===1&&await(await storage.readFile(p,'one')).text()==='shared bytes','same identity could not rehydrate a file pointer');assert(await storage.readFile(p,'two')===undefined,'rehydrating one identity revived another purged identity');
|
||||||
|
// 注入未来指针格式应报告缺少解释器;已知格式的摘要损坏应报告 corrupt_file_store,最后恢复原行。
|
||||||
const pointer=await get('files',[key,'one']);await put('files',[key,'one'],{...pointer,fileVersion:2});await rejects(()=>storage.readFile(p,'one'),'file_interpreter_unavailable');await rejects(()=>storage.scanFiles(p,{expectedRootRevision:3,limit:128}),'file_interpreter_unavailable');await put('files',[key,'one'],{...pointer,digest:'0'.repeat(64)});await rejects(()=>storage.readFile(p,'one'),'corrupt_file_store');await put('files',[key,'one'],pointer);
|
const pointer=await get('files',[key,'one']);await put('files',[key,'one'],{...pointer,fileVersion:2});await rejects(()=>storage.readFile(p,'one'),'file_interpreter_unavailable');await rejects(()=>storage.scanFiles(p,{expectedRootRevision:3,limit:128}),'file_interpreter_unavailable');await put('files',[key,'one'],{...pointer,digest:'0'.repeat(64)});await rejects(()=>storage.readFile(p,'one'),'corrupt_file_store');await put('files',[key,'one'],pointer);
|
||||||
|
// 旧版 DB 连接不能打开更高版本数据库,必须得到 VersionError,避免旧 reader 把指针误当 Blob。
|
||||||
const old=await new Promise(resolve=>{const request=indexedDB.open(name,3);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(old==='VersionError','DB v3 reader accepted pointer file rows');
|
const old=await new Promise(resolve=>{const request=indexedDB.open(name,3);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(old==='VersionError','DB v3 reader accepted pointer file rows');
|
||||||
}finally{raw?.close();await p?.release();await q?.release();storage.close();indexedDB.deleteDatabase(name);}
|
}finally{raw?.close();await p?.release();await q?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手工建 v3 数据库及校验有效的 checkpoint/Blob,交给新 storage 打开,再在同修订 reindex 时懒提升到指针。
|
||||||
export async function depotFilesUpgradeCase(assert){
|
export async function depotFilesUpgradeCase(assert){
|
||||||
const name=`depot-upgrade-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},key=JSON.stringify(['a','w','p']),authority='https://assets.test',bytes=new Blob(['old bytes'],{type:'image/png'}),hex=buffer=>[...new Uint8Array(buffer)].map(n=>n.toString(16).padStart(2,'0')).join(''),digest=hex(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer())),asset={id:'old',url:`${authority}/old`,width:2,height:1,bytes:bytes.size,mime_type:bytes.type},image={formatVersion:1,rootRevision:0,localRevision:0,payload:{runtime:{resources:[['old',{id:'old',digest,byteLength:bytes.size,mimeType:bytes.type,stored:asset}]]}}},checksum=hex(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(image))));
|
const name=`depot-upgrade-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},key=JSON.stringify(['a','w','p']),authority='https://assets.test',bytes=new Blob(['old bytes'],{type:'image/png'}),hex=buffer=>[...new Uint8Array(buffer)].map(n=>n.toString(16).padStart(2,'0')).join(''),digest=hex(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer())),asset={id:'old',url:`${authority}/old`,width:2,height:1,bytes:bytes.size,mime_type:bytes.type},image={formatVersion:1,rootRevision:0,localRevision:0,payload:{runtime:{resources:[['old',{id:'old',digest,byteLength:bytes.size,mimeType:bytes.type,stored:asset}]]}}},checksum=hex(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(image))));
|
||||||
|
// 模拟旧 schema 的 object stores,不通过新实现创建,确保升级路径确实面对旧数据库。
|
||||||
let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name,3);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files','asset-depot','asset-bytes','asset-pins'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name,3);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files','asset-depot','asset-bytes','asset-pins'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||||
|
// 在单个旧版事务里写 metadata、带 checksum 的 checkpoint 和原始 files Blob,然后关闭旧连接。
|
||||||
await new Promise((resolve,reject)=>{const tx=raw.transaction(['metadata','checkpoints','files'],'readwrite');tx.objectStore('metadata').put({fence:1,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image,checksum},[key,0]);tx.objectStore('files').put(bytes,[key,'old']);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});raw.close();const storage=createBrowserStorage({name});let leader;
|
await new Promise((resolve,reject)=>{const tx=raw.transaction(['metadata','checkpoints','files'],'readwrite');tx.objectStore('metadata').put({fence:1,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image,checksum},[key,0]);tx.objectStore('files').put(bytes,[key,'old']);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});raw.close();const storage=createBrowserStorage({name});let leader;
|
||||||
try{leader=await storage.claim(scope);assert((await storage.recover(leader)).rootRevision===0,'v3 checkpoint lost during upgrade');await storage.commit(leader,{image,depot:{version:1,authority,complete:true,pins:[['old',['element:old']]],writes:[{ref:{version:1,authority,digest,asset},bytes}]}});assert(await(await storage.readFile(leader,'old')).text()==='old bytes','idempotent reindex lost old bytes');raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const row=await new Promise((resolve,reject)=>{const request=raw.transaction('files').objectStore('files').get([key,'old']);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(raw.version===5&&row.fileVersion===1&&!(row instanceof Blob),'old project was not lazily promoted');raw.close();}finally{await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
try{leader=await storage.claim(scope);assert((await storage.recover(leader)).rootRevision===0,'v3 checkpoint lost during upgrade');await storage.commit(leader,{image,depot:{version:1,authority,complete:true,pins:[['old',['element:old']]],writes:[{ref:{version:1,authority,digest,asset},bytes}]}});assert(await(await storage.readFile(leader,'old')).text()==='old bytes','idempotent reindex lost old bytes');raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const row=await new Promise((resolve,reject)=>{const request=raw.transaction('files').objectStore('files').get([key,'old']);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(raw.version===5&&row.fileVersion===1&&!(row instanceof Blob),'old project was not lazily promoted');raw.close();}finally{await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 控制目标项目旧的无引用提交停在 IDB 事务之前,允许回收旧字节,再验证新接纳补回的字节不会被旧提交丢弃。
|
||||||
export async function depotAdoptionRaceCase(assert){
|
export async function depotAdoptionRaceCase(assert){
|
||||||
const {openProjectEngine}=await import('../../src/lib/canvas-runtime/engine.ts'),{createDocument,createRegistry,builtinComponents,applyChanges}=await import('../../src/lib/canvas-core/index.ts');
|
const {openProjectEngine}=await import('../../src/lib/canvas-runtime/engine.ts'),{createDocument,createRegistry,builtinComponents,applyChanges}=await import('../../src/lib/canvas-core/index.ts');
|
||||||
const name=`depot-race-${crypto.randomUUID()}`,base=createBrowserStorage({name}),scope={accountId:'a',workspaceId:'w',projectId:'source'},authority='https://assets.test',bytes=new Blob(['race bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),ref={version:1,authority,digest,asset:{id:'shared',url:`${authority}/shared`,width:2,height:1,bytes:bytes.size,mime_type:bytes.type}};let leader,engine,release,started,gate,blocked=false,version=1,hydrations=0;const receipts=new Map();
|
const name=`depot-race-${crypto.randomUUID()}`,base=createBrowserStorage({name}),scope={accountId:'a',workspaceId:'w',projectId:'source'},authority='https://assets.test',bytes=new Blob(['race bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),ref={version:1,authority,digest,asset:{id:'shared',url:`${authority}/shared`,width:2,height:1,bytes:bytes.size,mime_type:bytes.type}};let leader,engine,release,started,gate,blocked=false,version=1,hydrations=0;const receipts=new Map();
|
||||||
let document=createDocument('target',[{id:'canvas',parent:null}],[]);const registry=createRegistry(builtinComponents());
|
let document=createDocument('target',[{id:'canvas',parent:null}],[]);const registry=createRegistry(builtinComponents());
|
||||||
|
// 只阻塞一次 commit,并检查该旧 batch 没有 shared pin;其余接口仍委托真实 browser storage。
|
||||||
const storage={...base,async commit(held,batch){if(gate&&!blocked){blocked=true;assert(!batch.depot.pins.some(([id])=>id==='shared'),'older Root was not unpinned');started();await gate;}return base.commit(held,batch);}};
|
const storage={...base,async commit(held,batch){if(gate&&!blocked){blocked=true;assert(!batch.depot.pins.some(([id])=>id==='shared'),'older Root was not unpinned');started();await gate;}return base.commit(held,batch);}};
|
||||||
|
// authority 返回同一不可变身份,read 计数实际补回字节次数;任何误走上传的路径都会报错。
|
||||||
const assets={authority:{scope:authority,async resolve(){return ref;},async read(){hydrations++;return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/assets/${id}`,async upload(){throw Error('unexpected upload');},async query(){throw Error('unexpected upload query');},accept(){throw Error('unexpected upload receipt');}},persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
const assets={authority:{scope:authority,async resolve(){return ref;},async read(){hydrations++;return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/assets/${id}`,async upload(){throw Error('unexpected upload');},async query(){throw Error('unexpected upload query');},accept(){throw Error('unexpected upload receipt');}},persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
||||||
|
// 构造接纳图片并在同步事务内发布,之后用旧图删除/历史淘汰消除旧引用。
|
||||||
const picture=(handle,id)=>({id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}}),adopt=id=>engine.adoptResource('shared',(handle,tx)=>tx.dispatch({type:'element.create',element:picture(handle,id)}));
|
const picture=(handle,id)=>({id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}}),adopt=id=>engine.adoptResource('shared',(handle,tx)=>tx.dispatch({type:'element.create',element:picture(handle,id)}));
|
||||||
try{
|
try{
|
||||||
leader=await storage.claim(scope);await storage.commit(leader,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:null},depot:{version:1,authority,complete:true,pins:[],writes:[{ref,bytes}]}});
|
leader=await storage.claim(scope);await storage.commit(leader,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:null},depot:{version:1,authority,complete:true,pins:[],writes:[{ref,bytes}]}});
|
||||||
engine=await openProjectEngine({scope:{...scope,projectId:'target'},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,historyLimit:1});assert((await adopt('old')).status==='applied','initial adoption failed');await engine.settle();engine.editor.dispatch({type:'element.remove',target:{id:'old',incarnation:1}});await engine.settle();engine.editor.dispatch({type:'element.create',element:{...picture({id:'unused',src:'unused'},'text'),componentId:'text',data:{title:'text',text:''}}});await engine.settle();
|
engine=await openProjectEngine({scope:{...scope,projectId:'target'},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,historyLimit:1});assert((await adopt('old')).status==='applied','initial adoption failed');await engine.settle();engine.editor.dispatch({type:'element.remove',target:{id:'old',incarnation:1}});await engine.settle();engine.editor.dispatch({type:'element.create',element:{...picture({id:'unused',src:'unused'},'text'),componentId:'text',data:{title:'text',text:''}}});await engine.settle();
|
||||||
|
// 先等待旧无引用 batch 进入屏障,再进行 GC;这控制了可重现的竞态顺序,避免靠时间猜测。
|
||||||
const ready=new Promise(resolve=>started=resolve);gate=new Promise(resolve=>release=resolve);engine.editor.updateRuntime(previous=>({...previous}));await ready;
|
const ready=new Promise(resolve=>started=resolve);gate=new Promise(resolve=>release=resolve);engine.editor.updateRuntime(previous=>({...previous}));await ready;
|
||||||
// Old commit has not acquired IDB yet. A complete unpinned manifest permits GC.
|
// Old commit has not acquired IDB yet. A complete unpinned manifest permits GC.
|
||||||
await storage.assetDepot.collect(leader,{authority,now:0,retentionMs:0,limit:128});const purged=await storage.assetDepot.collect(leader,{authority,now:0,retentionMs:0,limit:128});assert(purged.purgedIds.includes('shared'),'GC could not retire the actually unreferenced identity');assert(!(await storage.assetDepot.read(leader,authority,'shared')).bytes,'GC left bytes before rehydration');
|
await storage.assetDepot.collect(leader,{authority,now:0,retentionMs:0,limit:128});const purged=await storage.assetDepot.collect(leader,{authority,now:0,retentionMs:0,limit:128});assert(purged.purgedIds.includes('shared'),'GC could not retire the actually unreferenced identity');assert(!(await storage.assetDepot.read(leader,authority,'shared')).bytes,'GC left bytes before rehydration');
|
||||||
|
// GC 已真实删除字节后,新接纳通过 authority.read 补回;释放旧 commit 后检查新字节和新 manifest 均保留。
|
||||||
assert((await adopt('new')).status==='applied'&&hydrations===1,'fresh adoption did not rehydrate original bytes');release();await engine.settle();const restored=await storage.assetDepot.read(leader,authority,'shared');assert(restored.bytes&&await restored.bytes.text()==='race bytes','older Root released the new adoption bytes before the matching durable cut');const stillPinned=await storage.assetDepot.collect(leader,{authority,now:1,retentionMs:0,limit:128});assert(stillPinned.pinnedIds.includes('shared'),'new Root did not atomically persist resource pins');
|
assert((await adopt('new')).status==='applied'&&hydrations===1,'fresh adoption did not rehydrate original bytes');release();await engine.settle();const restored=await storage.assetDepot.read(leader,authority,'shared');assert(restored.bytes&&await restored.bytes.text()==='race bytes','older Root released the new adoption bytes before the matching durable cut');const stillPinned=await storage.assetDepot.collect(leader,{authority,now:1,retentionMs:0,limit:128});assert(stillPinned.pinnedIds.includes('shared'),'new Root did not atomically persist resource pins');
|
||||||
}finally{release?.();await engine?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
}finally{release?.();await engine?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 按元素ID分桶的文档订阅测试。用lookup计数、加载覆盖及无change列表的ACK替换,检查局部通知不漏更新也不做无关读取。
|
||||||
|
* 模拟ports只实现测试需要的记录查找,不执行真实editor命令或网络请求。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createElementSources} from '../../src/lib/canvas-bindings/element-sources.ts';
|
import {createElementSources} from '../../src/lib/canvas-bindings/element-sources.ts';
|
||||||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||||
|
/** 按count建元素表;lookup区分present/unloaded/absent并记录ID,edit携带field变化,emit可模拟整根替换。 */
|
||||||
function fixture(count=3){
|
function fixture(count=3){
|
||||||
const element=id=>({id,incarnation:1,ownerCanvasId:'root',data:{title:id}}),document={elements:new Map(Array.from({length:count},(_,index)=>{const id=String(index);return[id,element(id)];}))};let root={document,runtime:{acknowledged:document}},throwSubscribe=false;const listeners=new Set(),lookups=[];
|
const element=id=>({id,incarnation:1,ownerCanvasId:'root',data:{title:id}}),document={elements:new Map(Array.from({length:count},(_,index)=>{const id=String(index);return[id,element(id)];}))};let root={document,runtime:{acknowledged:document}},throwSubscribe=false;const listeners=new Set(),lookups=[];
|
||||||
|
// throwSubscribe模拟启动失败,lookups观察实际读过哪些ID;订阅返回的清理函数只移除对应监听。
|
||||||
const ports={getSnapshot:()=>root,subscribe(fn){if(throwSubscribe)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);},lookup(snapshot,id){lookups.push(id);const element=snapshot.document.elements.get(id);return element?{status:'present',element}:{status:snapshot.runtime.loading?'unloaded':'absent'};}};
|
const ports={getSnapshot:()=>root,subscribe(fn){if(throwSubscribe)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);},lookup(snapshot,id){lookups.push(id);const element=snapshot.document.elements.get(id);return element?{status:'present',element}:{status:snapshot.runtime.loading?'unloaded':'absent'};}};
|
||||||
return{ports,lookups,element,get size(){return listeners.size;},root:()=>root,throwSubscribe(value){throwSubscribe=value;},emit(next,changes=[]){root=next;for(const fn of [...listeners])fn({changes},root);},edit(id,title){const elements=new Map(root.document.elements);elements.set(id,{...elements.get(id),data:{title}});this.emit({...root,document:{elements}},[{kind:'field',target:{id,incarnation:1},field:'data'}]);}};
|
return{ports,lookups,element,get size(){return listeners.size;},root:()=>root,throwSubscribe(value){throwSubscribe=value;},emit(next,changes=[]){root=next;for(const fn of [...listeners])fn({changes},root);},edit(id,title){const elements=new Map(root.document.elements);elements.set(id,{...elements.get(id),data:{title}});this.emit({...root,document:{elements}},[{kind:'field',target:{id,incarnation:1},field:'data'}]);}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1000元素通道共享上游,编辑300只查300/通知300;无关runtime发布不重新lookup任何元素。
|
||||||
test('one ID edit among 1000 subscriptions evaluates only that target; runtime-only cuts do no element work',()=>{
|
test('one ID edit among 1000 subscriptions evaluates only that target; runtime-only cuts do no element work',()=>{
|
||||||
const f=fixture(1000),sources=createElementSources(f.ports),calls=new Map(),notified=new Set(),stops=[];
|
const f=fixture(1000),sources=createElementSources(f.ports),calls=new Map(),notified=new Set(),stops=[];
|
||||||
for(let index=0;index<1000;index++){const id=String(index),store=createSelectorStore(sources.forElement(id),view=>{calls.set(id,(calls.get(id)??0)+1);return view.element.data.title;});stops.push(store.subscribe(()=>notified.add(id)));}
|
for(let index=0;index<1000;index++){const id=String(index),store=createSelectorStore(sources.forElement(id),view=>{calls.set(id,(calls.get(id)??0)+1);return view.element.data.title;});stops.push(store.subscribe(()=>notified.add(id)));}
|
||||||
@@ -14,31 +21,38 @@ test('one ID edit among 1000 subscriptions evaluates only that target; runtime-o
|
|||||||
f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,metadata:1}});assert.equal(f.lookups.length,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,metadata:1}});assert.equal(f.lookups.length,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ACK没有changes清单但替换document:应检查活跃目标,深相等克隆保持0原快照,真正改1仍通知。
|
||||||
test('ACK replacement checks active targets, ignores equal cloned records and delivers unlisted changes',()=>{
|
test('ACK replacement checks active targets, ignores equal cloned records and delivers unlisted changes',()=>{
|
||||||
const f=fixture(),sources=createElementSources(f.ports),seen=[];const a=sources.forElement('0'),b=sources.forElement('1'),stopA=a.subscribe(()=>seen.push('0')),stopB=b.subscribe(()=>seen.push('1')),old=a.getSnapshot();
|
const f=fixture(),sources=createElementSources(f.ports),seen=[];const a=sources.forElement('0'),b=sources.forElement('1'),stopA=a.subscribe(()=>seen.push('0')),stopB=b.subscribe(()=>seen.push('1')),old=a.getSnapshot();
|
||||||
const elements=new Map([...f.root().document.elements].map(([id,row])=>[id,structuredClone(row)]));elements.get('1').data.title='canonical';f.emit({...f.root(),document:{elements}});assert.deepEqual(seen,['1']);assert.strictEqual(a.getSnapshot(),old);assert.equal(b.getSnapshot().element.data.title,'canonical');stopA();stopB();
|
const elements=new Map([...f.root().document.elements].map(([id,row])=>[id,structuredClone(row)]));elements.get('1').data.title='canonical';f.emit({...f.root(),document:{elements}});assert.deepEqual(seen,['1']);assert.strictEqual(a.getSnapshot(),old);assert.equal(b.getSnapshot().element.data.title,'canonical');stopA();stopB();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 加载结束让missing从unloaded变absent,只唤醒缺失目标,present对象不受影响。
|
||||||
test('loading coverage invalidates missing lookups at EOF without waking present targets',()=>{
|
test('loading coverage invalidates missing lookups at EOF without waking present targets',()=>{
|
||||||
const f=fixture(),sources=createElementSources(f.ports);f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6}}});const missing=sources.forElement('later'),present=sources.forElement('0'),seen=[];const a=missing.subscribe(()=>seen.push('missing')),b=present.subscribe(()=>seen.push('present'));assert.equal(missing.getSnapshot().status,'unloaded');f.emit({...f.root(),runtime:{...f.root().runtime,loading:undefined}});assert.deepEqual(seen,['missing']);assert.equal(missing.getSnapshot().status,'absent');a();b();
|
const f=fixture(),sources=createElementSources(f.ports);f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6}}});const missing=sources.forElement('later'),present=sources.forElement('0'),seen=[];const a=missing.subscribe(()=>seen.push('missing')),b=present.subscribe(()=>seen.push('present'));assert.equal(missing.getSnapshot().status,'unloaded');f.emit({...f.root(),runtime:{...f.root().runtime,loading:undefined}});assert.deepEqual(seen,['missing']);assert.equal(missing.getSnapshot().status,'absent');a();b();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 退订期间编辑不通知但getSnapshot仍新;再次订阅从当前基线开始,重复stop不泄漏上游。
|
||||||
test('getSnapshot stays fresh while unsubscribed, repeated cleanup is safe and late subscriptions do not reuse old baselines',()=>{
|
test('getSnapshot stays fresh while unsubscribed, repeated cleanup is safe and late subscriptions do not reuse old baselines',()=>{
|
||||||
const f=fixture(),sources=createElementSources(f.ports),channel=sources.forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++);stop();stop();f.edit('0','unsubscribed');assert.equal(calls,0);assert.equal(channel.getSnapshot().element.data.title,'unsubscribed');const again=channel.subscribe(()=>calls++);f.edit('0','current');assert.equal(calls,1);again();assert.equal(f.size,0);
|
const f=fixture(),sources=createElementSources(f.ports),channel=sources.forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++);stop();stop();f.edit('0','unsubscribed');assert.equal(calls,0);assert.equal(channel.getSnapshot().element.data.title,'unsubscribed');const again=channel.subscribe(()=>calls++);f.edit('0','current');assert.equal(calls,1);again();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 上游启动失败不残留;同次修改0/1时0观察者抛错,1仍收到通知,最后全部清理。
|
||||||
test('observer failures do not starve other buckets and failed upstream subscription retains no listener',()=>{
|
test('observer failures do not starve other buckets and failed upstream subscription retains no listener',()=>{
|
||||||
const f=fixture(),sources=createElementSources(f.ports);f.throwSubscribe(true);assert.throws(()=>sources.forElement('0').subscribe(()=>{}),/subscribe failed/);f.throwSubscribe(false);let b=0;const a=sources.forElement('0').subscribe(()=>{throw Error('observer failed');}),stopB=sources.forElement('1').subscribe(()=>b++),elements=new Map(f.root().document.elements);for(const id of ['0','1'])elements.set(id,{...elements.get(id),data:{title:'new'}});assert.throws(()=>f.emit({...f.root(),document:{elements}},['0','1'].map(id=>({kind:'element',id}))),/observer failed/);assert.equal(b,1);a();stopB();assert.equal(f.size,0);
|
const f=fixture(),sources=createElementSources(f.ports);f.throwSubscribe(true);assert.throws(()=>sources.forElement('0').subscribe(()=>{}),/subscribe failed/);f.throwSubscribe(false);let b=0;const a=sources.forElement('0').subscribe(()=>{throw Error('observer failed');}),stopB=sources.forElement('1').subscribe(()=>b++),elements=new Map(f.root().document.elements);for(const id of ['0','1'])elements.set(id,{...elements.get(id),data:{title:'new'}});assert.throws(()=>f.emit({...f.root(),document:{elements}},['0','1'].map(id=>({kind:'element',id}))),/observer failed/);assert.equal(b,1);a();stopB();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更早监听先退掉唯一元素订阅,后续捕获的上游回调须安全忽略,不读已清除基线。
|
||||||
test('last unsubscribe before the captured upstream callback runs does not retain or dereference a dead baseline',()=>{
|
test('last unsubscribe before the captured upstream callback runs does not retain or dereference a dead baseline',()=>{
|
||||||
const f=fixture();let stop;const earlier=f.ports.subscribe(()=>stop()),sources=createElementSources(f.ports);let notified=0;stop=sources.forElement('0').subscribe(()=>notified++);assert.doesNotThrow(()=>f.edit('0','after detach'));assert.equal(notified,0);assert.equal(f.size,1);earlier();assert.equal(f.size,0);
|
const f=fixture();let stop;const earlier=f.ports.subscribe(()=>stop()),sources=createElementSources(f.ports);let notified=0;stop=sources.forElement('0').subscribe(()=>notified++);assert.doesNotThrow(()=>f.edit('0','after detach'));assert.equal(notified,0);assert.equal(f.size,1);earlier();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// lookup暂时抛错也需通知,读者重读看到异常;恢复到同一旧值仍要再通知,解除失败状态。
|
||||||
test('lookup failure notifies readers and clearing the failure re-notifies even when the prior value is restored',()=>{
|
test('lookup failure notifies readers and clearing the failure re-notifies even when the prior value is restored',()=>{
|
||||||
const f=fixture(),lookup=f.ports.lookup;let fail=false;f.ports.lookup=(...args)=>{if(fail)throw Error('lookup failed');return lookup(...args);};const channel=createElementSources(f.ports).forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot();fail=true;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/lookup failed/);fail=false;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();
|
const f=fixture(),lookup=f.ports.lookup;let fail=false;f.ports.lookup=(...args)=>{if(fail)throw Error('lookup failed');return lookup(...args);};const channel=createElementSources(f.ports).forElement('0');let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot();fail=true;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/lookup failed/);fail=false;f.emit({...f.root(),document:{...f.root().document}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// loadingRead文档未变时ACK前进不改变missing的读取依据;只有捕获读取基线换引用才重新lookup。
|
||||||
test('an advanced ACK does not recheck missing IDs against an unchanged captured loading baseline',()=>{
|
test('an advanced ACK does not recheck missing IDs against an unchanged captured loading baseline',()=>{
|
||||||
const f=fixture(),base=f.root().document;f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6},loadingRead:{document:base}}});const sources=createElementSources(f.ports),stop=sources.forElement('missing').subscribe(()=>{});f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,acknowledged:{...base}}});assert.deepEqual(f.lookups,[]);f.emit({...f.root(),runtime:{...f.root().runtime,loadingRead:{document:{...base}}}});assert.deepEqual(f.lookups,['missing']);stop();
|
const f=fixture(),base=f.root().document;f.emit({...f.root(),runtime:{...f.root().runtime,loading:{version:6},loadingRead:{document:base}}});const sources=createElementSources(f.ports),stop=sources.forElement('missing').subscribe(()=>{});f.lookups.length=0;f.emit({...f.root(),runtime:{...f.root().runtime,acknowledged:{...base}}});assert.deepEqual(f.lookups,[]);f.emit({...f.root(),runtime:{...f.root().runtime,loadingRead:{document:{...base}}}});assert.deepEqual(f.lookups,['missing']);stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 元素静态校验缓存的正确性测试。区分框架取得所有权的不可变记录与调用方浅冻结/访问器输入,验证优化不能绕过动态schema或文档拓扑。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,validateElementRecord,validateDocument,validateDocumentSteps,createRegistry,defineComponent,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
|
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'}});
|
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};}
|
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',()=>{
|
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/);
|
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',()=>{
|
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/);
|
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',()=>{
|
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/);
|
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',()=>{
|
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();
|
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',()=>{
|
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}}])];
|
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};}};
|
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)){};}));
|
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);
|
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',()=>{
|
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;}
|
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/);
|
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/);
|
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',()=>{
|
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/);
|
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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 未发送操作的本地抵消证明测试:完整创建到删除的封闭生命周期可以不产生请求,但必须保存身份高水位。
|
||||||
|
* 抵消需证明操作连续、没有观察者遗漏且没有可能执行的网络证据;资源仍可由撤销历史持有。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDocument, createRegistry, builtinComponents, applyChanges } from '../../src/lib/canvas-core/index.ts';
|
import { createDocument, createRegistry, builtinComponents, applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -5,15 +9,20 @@ import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
|||||||
import { elisionBatchLimit, findElision, proveElision, validateElisions, withLocalLifecycles } from '../../src/lib/canvas-runtime/elision.ts';
|
import { elisionBatchLimit, findElision, proveElision, validateElisions, withLocalLifecycles } from '../../src/lib/canvas-runtime/elision.ts';
|
||||||
import { replayPending } from '../../src/lib/canvas-runtime/reconcile.ts';
|
import { replayPending } from '../../src/lib/canvas-runtime/reconcile.ts';
|
||||||
import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
|
import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
|
||||||
|
// 共享只读空基线与内置定义;各用例从该基线创建新工作文档,不修改基线本身。
|
||||||
const registry=createRegistry(builtinComponents()),base=createDocument('project',[{id:'canvas',parent:null}],[]);
|
const registry=createRegistry(builtinComponents()),base=createDocument('project',[{id:'canvas',parent:null}],[]);
|
||||||
|
// 创建指定 ID、归属与 incarnation 的文本样本,用于生命周期及原型敏感名称检查。
|
||||||
const node=(id='new',ownerCanvasId='canvas',incarnation=1)=>({id,ownerCanvasId,incarnation,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
const node=(id='new',ownerCanvasId='canvas',incarnation=1)=>({id,ownerCanvasId,incarnation,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||||
|
// 分别构造元素创建和删除变化;前后快照为抵消证明提供完整身份及内容依据。
|
||||||
const create=element=>({kind:'element',id:element.id,before:null,after:element}),remove=element=>({kind:'element',id:element.id,before:element,after:null});
|
const create=element=>({kind:'element',id:element.id,before:null,after:element}),remove=element=>({kind:'element',id:element.id,before:element,after:null});
|
||||||
|
// 将分块变化登记为线性依赖链,并记录每个操作首次看到的对象存在性;proof 每次读取最新账本。
|
||||||
function fixture(chunks,document=base){
|
function fixture(chunks,document=base){
|
||||||
const ledger=createLedger(),intents=chunks.map((changes,index)=>({id:`op-${index}`,operationType:'document.change',operationSchemaVersion:1,sequence:index+1,origin:'user',canvasIds:['canvas'],changes,dependencies:index?[`op-${index-1}`]:[]}));
|
const ledger=createLedger(),intents=chunks.map((changes,index)=>({id:`op-${index}`,operationType:'document.change',operationSchemaVersion:1,sequence:index+1,origin:'user',canvasIds:['canvas'],changes,dependencies:index?[`op-${index-1}`]:[]}));
|
||||||
for(const intent of intents){const presence=Object.create(null);for(const change of intent.changes)if(change.kind!=='canvas'){const id=change.kind==='field'?change.target.id:change.id;presence[id]??=change.kind==='element'&&!change.before?'absent':'exists';}ledger.add(intent,presence);}
|
for(const intent of intents){const presence=Object.create(null);for(const change of intent.changes)if(change.kind!=='canvas'){const id=change.kind==='field'?change.target.id:change.id;presence[id]??=change.kind==='element'&&!change.before?'absent':'exists';}ledger.add(intent,presence);}
|
||||||
return{ledger,intents,proof:()=>findElision(intents,document,ledger.getSnapshot())};
|
return{ledger,intents,proof:()=>findElision(intents,document,ledger.getSnapshot())};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建、改名、删除可整体抵消,不产生请求;仅在本地重放中保留 incarnation 墓碑,操作标记为 elided。
|
||||||
test('closed create/update/delete lifetime retires without a request and preserves only local high-water marks',()=>{
|
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();
|
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.deepEqual(proof.operationIds,['op-0','op-1','op-2']);assert.deepEqual(proof.tombstones,{new:1});f.ledger.elide(proof);validateElisions(f.ledger.getSnapshot());
|
||||||
@@ -21,17 +30,20 @@ test('closed create/update/delete lifetime retires without a request and preserv
|
|||||||
for(const record of f.ledger.getSnapshot().operations.values()){assert.equal(record.resolution,'elided');assert.equal(record.requestId,undefined);}
|
for(const record of f.ledger.getSnapshot().operations.values()){assert.equal(record.resolution,'elided');assert.equal(record.requestId,undefined);}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 已有元素的后续修改不属于封闭生命周期:只抵消前两个操作,后续标题修改继续 pending 并可重放。
|
||||||
test('the longest balanced prefix can retire before a later existing-element edit',()=>{
|
test('the longest balanced prefix can retire before a later existing-element edit',()=>{
|
||||||
const a=node('a'),document=createDocument('project',[{id:'canvas',parent:null}],[a]),n=node(),f=fixture([[create(n)],[remove(n)],[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'edited'}]],document);
|
const a=node('a'),document=createDocument('project',[{id:'canvas',parent:null}],[a]),n=node(),f=fixture([[create(n)],[remove(n)],[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'edited'}]],document);
|
||||||
const proof=f.proof();assert.deepEqual(proof.operationIds,['op-0','op-1']);f.ledger.elide(proof);
|
const proof=f.proof();assert.deepEqual(proof.operationIds,['op-0','op-1']);f.ledger.elide(proof);
|
||||||
const working=replayPending(document,f.ledger.getSnapshot(),registry);assert.equal(working.elements.get('a').data.title,'edited');assert.equal(working.tombstones.get('new'),1);assert.equal(f.ledger.getSnapshot().operations.get('op-2').phase,'pending');
|
const working=replayPending(document,f.ledger.getSnapshot(),registry);assert.equal(working.elements.get('a').data.title,'edited');assert.equal(working.tombstones.get('new'),1);assert.equal(f.ledger.getSnapshot().operations.get('op-2').phase,'pending');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 分区入口、子 canvas 与成员一起创建再删除时可抵消;仍记录元素墓碑,不给服务端虚构 canvas 版本。
|
||||||
test('closed partitions retain entry/member lifecycles without inventing server canvas versions',()=>{
|
test('closed partitions retain entry/member lifecycles without inventing server canvas versions',()=>{
|
||||||
const frame={...node('frame'),componentId:'frame'},member=node('member','child'),canvas={id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}},changes=[create(frame),{kind:'canvas',id:'child',before:null,after:canvas},create(member)],inverse=[remove(member),{kind:'canvas',id:'child',before:canvas,after:null},remove(frame)],f=fixture([changes,inverse]);
|
const frame={...node('frame'),componentId:'frame'},member=node('member','child'),canvas={id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}},changes=[create(frame),{kind:'canvas',id:'child',before:null,after:canvas},create(member)],inverse=[remove(member),{kind:'canvas',id:'child',before:canvas,after:null},remove(frame)],f=fixture([changes,inverse]);
|
||||||
f.ledger.elide(f.proof());const working=replayPending(base,f.ledger.getSnapshot(),registry);assert.equal(working.canvases.size,1);assert.deepEqual(Object.fromEntries(working.tombstones),{member:1,frame:1});assert.equal(base.canvases.has('child'),false);
|
f.ledger.elide(f.proof());const working=replayPending(base,f.ledger.getSnapshot(),registry);assert.equal(working.canvases.size,1);assert.deepEqual(Object.fromEntries(working.tombstones),{member:1,frame:1});assert.equal(base.canvases.has('child'),false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 逐一注入未知基线、阻塞、冻结和已标记派发状态,任一条件都使本地抵消证明不可用。
|
||||||
test('unknown, blocked, frozen and possibly-applied creation cannot be elided',()=>{
|
test('unknown, blocked, frozen and possibly-applied creation cannot be elided',()=>{
|
||||||
const n=node();
|
const n=node();
|
||||||
for(const state of ['unknown','blocked','frozen','dispatch']){
|
for(const state of ['unknown','blocked','frozen','dispatch']){
|
||||||
@@ -42,6 +54,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',()=>{
|
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');
|
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');
|
||||||
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'});
|
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'});
|
||||||
@@ -49,6 +62,7 @@ test('a skipped observer, forged tombstone or reused incarnation cannot acquire
|
|||||||
assert.throws(()=>proveElision([{...createIntent,changes:[create(n),remove(n),create(n),remove(n)]}],{}),/stale_incarnation/);
|
assert.throws(()=>proveElision([{...createIntent,changes:[create(n),remove(n),create(n),remove(n)]}],{}),/stale_incarnation/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 篡改已保存证明的归属、墓碑和基线时恢复校验拒绝;未来证明版本单独报告缺少解释器。
|
||||||
test('persisted elision groups validate ownership, scope continuity, version and exact lifecycle evidence',()=>{
|
test('persisted elision groups validate ownership, scope continuity, version and exact lifecycle evidence',()=>{
|
||||||
const n=node(),f=fixture([[create(n)],[remove(n)]]);f.ledger.elide(f.proof());const saved=f.ledger.getSnapshot();
|
const n=node(),f=fixture([[create(n)],[remove(n)]]);f.ledger.elide(f.proof());const saved=f.ledger.getSnapshot();
|
||||||
for(const change of [record=>({...record,elisionId:'missing'}),record=>({...record,elision:{...record.elision,tombstones:{new:2}}}),record=>({...record,basePresence:{new:'unknown'}})]){
|
for(const change of [record=>({...record,elisionId:'missing'}),record=>({...record,elision:{...record.elision,tombstones:{new:2}}}),record=>({...record,basePresence:{new:'unknown'}})]){
|
||||||
@@ -57,6 +71,7 @@ test('persisted elision groups validate ownership, scope continuity, version and
|
|||||||
const operations=new Map(saved.operations),anchor=operations.get('op-0');operations.set('op-0',{...anchor,elision:{...anchor.elision,version:2}});assert.throws(()=>validateElisions({operations}),/elision_interpreter_unavailable/);
|
const operations=new Map(saved.operations),anchor=operations.get('op-0');operations.set('op-0',{...anchor,elision:{...anchor.elision,version:2}});assert.throws(()=>validateElisions({operations}),/elision_interpreter_unavailable/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 即使文档操作已抵消,undo 历史仍引用图片;资源 pin 必须同时保留创建和删除历史的来源。
|
||||||
test('resource references remain pinned by undo history after the document operations retire',()=>{
|
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());
|
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());
|
||||||
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());
|
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());
|
||||||
@@ -64,6 +79,7 @@ test('resource references remain pinned by undo history after the document opera
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 上一段本地生命周期留下的高水位仍有效,普通和原型敏感 ID 均不能以旧 incarnation 再次抵消,失败保持原快照。
|
||||||
test('a subsequent cancellation group cannot reuse an incarnation from a prior local lifetime',()=>{
|
test('a subsequent cancellation group cannot reuse an incarnation from a prior local lifetime',()=>{
|
||||||
for(const id of ['new','constructor','__proto__']){
|
for(const id of ['new','constructor','__proto__']){
|
||||||
const n=node(id),f=fixture([[create(n)],[remove(n)]]);f.ledger.elide(f.proof());
|
const n=node(id),f=fixture([[create(n)],[remove(n)]]);f.ledger.elide(f.proof());
|
||||||
@@ -73,6 +89,7 @@ test('a subsequent cancellation group cannot reuse an incarnation from a prior l
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 真实的已有元素修改使前批在该处截断;后续封闭片段不能忽略外部依赖单独抵消,而独立封闭链可完整入批。
|
||||||
test('a preceding real edit is split from a later closed segment, including explicit preceding dependencies',()=>{
|
test('a preceding real edit is split from a later closed segment, including explicit preceding dependencies',()=>{
|
||||||
const a=node('a'),n=node(),document=createDocument('project',[{id:'canvas',parent:null}],[a]),f=fixture([[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'edited'}],[create(n)],[remove(n)]],document);
|
const a=node('a'),n=node(),document=createDocument('project',[{id:'canvas',parent:null}],[a]),f=fixture([[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'edited'}],[create(n)],[remove(n)]],document);
|
||||||
assert.equal(elisionBatchLimit(f.intents,document,f.ledger.getSnapshot()),1);assert.equal(findElision(f.intents.slice(1),document,f.ledger.getSnapshot()),undefined);
|
assert.equal(elisionBatchLimit(f.intents,document,f.ledger.getSnapshot()),1);assert.equal(findElision(f.intents.slice(1),document,f.ledger.getSnapshot()),undefined);
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 呈现帧队列与外部store适配的调度测试。用可手动推进或故意不执行cancel的时钟,验证合并、立即回显、迟到回调及重入隔离。
|
||||||
|
* 延迟的是通知呈现,不是editor提交或getSnapshot中的最新事实;这里不运行真实requestAnimationFrame。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createFrameQueue,createFrameSource} from '../../src/lib/canvas-bindings/frame-queue.ts';
|
import {createFrameQueue,createFrameSource} from '../../src/lib/canvas-bindings/frame-queue.ts';
|
||||||
import {createSessionFrameSource} from '../../src/lib/canvas-bindings/session-frames.ts';
|
import {createSessionFrameSource} from '../../src/lib/canvas-bindings/session-frames.ts';
|
||||||
|
/** Map保存待执行任务,step先取出并清一批再执行,回调中新排的任务留下一帧;size用于检查调度是否泄漏。 */
|
||||||
function clock(){let id=0;const callbacks=new Map();return{request(fn){callbacks.set(++id,fn);return id;},cancel(id){callbacks.delete(id);},step(){const batch=[...callbacks.values()];callbacks.clear();for(const fn of batch)fn();},get size(){return callbacks.size;}};}
|
function clock(){let id=0;const callbacks=new Map();return{request(fn){callbacks.set(++id,fn);return id;},cancel(id){callbacks.delete(id);},step(){const batch=[...callbacks.values()];callbacks.clear();for(const fn of batch)fn();},get size(){return callbacks.size;}};}
|
||||||
|
// 重复a与b合成一个dirty集合,flush期间新增next留后帧;dispose后排队和新invalidate都不能发布。
|
||||||
test('frame dirty Set deduplicates bursts, flushes last invalidation and preserves reentrant work for the next frame',()=>{const c=clock(),batches=[];let q;q=createFrameQueue(c,dirty=>{batches.push([...dirty]);if(dirty.has('a'))q.invalidate(['next']);});q.invalidate(['a','a']);q.invalidate(['b']);assert.equal(c.size,1);q.flush();assert.deepEqual(batches,[['a','b']]);assert.equal(c.size,1);c.step();assert.deepEqual(batches,[['a','b'],['next']]);q.invalidate(['discard']);q.dispose();c.step();q.invalidate(['closed']);assert.equal(batches.length,2);assert.equal(c.size,0);});
|
test('frame dirty Set deduplicates bursts, flushes last invalidation and preserves reentrant work for the next frame',()=>{const c=clock(),batches=[];let q;q=createFrameQueue(c,dirty=>{batches.push([...dirty]);if(dirty.has('a'))q.invalidate(['next']);});q.invalidate(['a','a']);q.invalidate(['b']);assert.equal(c.size,1);q.flush();assert.deepEqual(batches,[['a','b']]);assert.equal(c.size,1);c.step();assert.deepEqual(batches,[['a','b'],['next']]);q.invalidate(['discard']);q.dispose();c.step();q.invalidate(['closed']);assert.equal(batches.length,2);assert.equal(c.size,0);});
|
||||||
|
// 假cancel不移除函数,手动执行旧回调模拟迟到;代次检查必须阻止旧批次或已dispose队列再次通知。
|
||||||
test('canceled late callbacks cannot publish a replaced or disposed frame',()=>{const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},batches=[];const q=createFrameQueue(c,dirty=>batches.push([...dirty]));q.invalidate(['first']);q.flush();q.invalidate(['second']);callbacks[0]();assert.deepEqual(batches,[['first']]);q.dispose();callbacks[1]();assert.equal(batches.length,1);});
|
test('canceled late callbacks cannot publish a replaced or disposed frame',()=>{const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},batches=[];const q=createFrameQueue(c,dirty=>batches.push([...dirty]));q.invalidate(['first']);q.flush();q.invalidate(['second']);callbacks[0]();assert.deepEqual(batches,[['first']]);q.dispose();callbacks[1]();assert.equal(batches.length,1);});
|
||||||
|
// 20次预览只排一帧,但读取立即得到x=19;取消后读取立即变null,退订清理上游及待呈现通知。
|
||||||
test('session presentation coalesces preview bursts but live reads and final cancel remain immediate; unsubscribe drops pending frame',()=>{const c=clock(),listeners=new Set();let snapshot={views:new Map(),gesture:null,connectionGesture:null};const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createSessionFrameSource(source,c);let paints=0;const stop=framed.subscribe(()=>paints++);const emit=next=>{snapshot=next;for(const fn of listeners)fn();};for(let i=0;i<20;i++)emit({...snapshot,gesture:{targets:[{id:'a',incarnation:1}],preview:new Map([['a',{x:i}]])}});assert.equal(c.size,1);assert.equal(paints,0);assert.equal(framed.getSnapshot().gesture.preview.get('a').x,19);c.step();assert.equal(paints,1);emit({...snapshot,gesture:null});assert.equal(framed.getSnapshot().gesture,null);c.step();assert.equal(paints,2);emit({...snapshot,views:new Map([['v',{}]])});stop();c.step();assert.equal(paints,2);assert.equal(listeners.size,0);});
|
test('session presentation coalesces preview bursts but live reads and final cancel remain immediate; unsubscribe drops pending frame',()=>{const c=clock(),listeners=new Set();let snapshot={views:new Map(),gesture:null,connectionGesture:null};const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createSessionFrameSource(source,c);let paints=0;const stop=framed.subscribe(()=>paints++);const emit=next=>{snapshot=next;for(const fn of listeners)fn();};for(let i=0;i<20;i++)emit({...snapshot,gesture:{targets:[{id:'a',incarnation:1}],preview:new Map([['a',{x:i}]])}});assert.equal(c.size,1);assert.equal(paints,0);assert.equal(framed.getSnapshot().gesture.preview.get('a').x,19);c.step();assert.equal(paints,1);emit({...snapshot,gesture:null});assert.equal(framed.getSnapshot().gesture,null);c.step();assert.equal(paints,2);emit({...snapshot,views:new Map([['v',{}]])});stop();c.step();assert.equal(paints,2);assert.equal(listeners.size,0);});
|
||||||
|
|
||||||
|
// 三次编辑已提交且产生三条历史,画面通知合成一次;getSnapshot在帧执行前也返回最新editor根。
|
||||||
test('root presentation batches a burst while commands, history and authoritative reads advance immediately',async()=>{
|
test('root presentation batches a burst while commands, history and authoritative reads advance immediately',async()=>{
|
||||||
const {createDocument,createEditor,createRegistry,builtinComponents}=await import('../../src/lib/canvas-core/index.ts');let id=0;
|
const {createDocument,createEditor,createRegistry,builtinComponents}=await import('../../src/lib/canvas-core/index.ts');let id=0;
|
||||||
const editor=createEditor({document:createDocument('p',[{id:'canvas',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:'before',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]),registry:createRegistry(builtinComponents()),createId:()=>`op-${++id}`}),c=clock(),commits=[],source={getSnapshot:editor.getSnapshot,subscribe:notify=>editor.subscribe((commit,next)=>{commits.push(next.rootRevision);notify();})},framed=createFrameSource(source,c),painted=[],stop=framed.subscribe(()=>painted.push(framed.getSnapshot()));
|
const editor=createEditor({document:createDocument('p',[{id:'canvas',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:'before',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]),registry:createRegistry(builtinComponents()),createId:()=>`op-${++id}`}),c=clock(),commits=[],source={getSnapshot:editor.getSnapshot,subscribe:notify=>editor.subscribe((commit,next)=>{commits.push(next.rootRevision);notify();})},framed=createFrameSource(source,c),painted=[],stop=framed.subscribe(()=>painted.push(framed.getSnapshot()));
|
||||||
for(const title of ['first','second','third'])assert.equal(editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title}}).status,'applied');
|
for(const title of ['first','second','third'])assert.equal(editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title}}).status,'applied');
|
||||||
assert.equal(commits.length,3);assert.equal(painted.length,0);assert.equal(c.size,1);assert.strictEqual(framed.getSnapshot(),editor.getSnapshot());assert.equal(framed.getSnapshot().document.elements.get('a').data.title,'third');assert.equal(editor.getSnapshot().history.undo.length,3);c.step();assert.equal(painted.length,1);assert.strictEqual(painted[0],editor.getSnapshot());stop();editor.dispose();
|
assert.equal(commits.length,3);assert.equal(painted.length,0);assert.equal(c.size,1);assert.strictEqual(framed.getSnapshot(),editor.getSnapshot());assert.equal(framed.getSnapshot().document.elements.get('a').data.title,'third');assert.equal(editor.getSnapshot().history.undo.length,3);c.step();assert.equal(painted.length,1);assert.strictEqual(painted[0],editor.getSnapshot());stop();editor.dispose();
|
||||||
});
|
});
|
||||||
|
// A退订后B新订阅,各自回调代次隔离;手动执行A旧函数不能通知B或恢复A。
|
||||||
test('frame source subscriptions own their queues and late callbacks cannot notify an unsubscribed view',()=>{
|
test('frame source subscriptions own their queues and late callbacks cannot notify an unsubscribed view',()=>{
|
||||||
const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},listeners=new Set();let snapshot=0;
|
const callbacks=[],c={request(fn){callbacks.push(fn);return callbacks.length;},cancel(){}},listeners=new Set();let snapshot=0;
|
||||||
const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createFrameSource(source,c);let a=0,b=0;const stopA=framed.subscribe(()=>a++);snapshot++;for(const fn of listeners)fn();stopA();const stopB=framed.subscribe(()=>b++);snapshot++;for(const fn of listeners)fn();callbacks[0]();assert.equal(a,0);assert.equal(b,0);callbacks[1]();assert.equal(b,1);assert.equal(framed.getSnapshot(),2);stopB();assert.equal(listeners.size,0);
|
const source={getSnapshot:()=>snapshot,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);}},framed=createFrameSource(source,c);let a=0,b=0;const stopA=framed.subscribe(()=>a++);snapshot++;for(const fn of listeners)fn();stopA();const stopB=framed.subscribe(()=>b++);snapshot++;for(const fn of listeners)fn();callbacks[0]();assert.equal(a,0);assert.equal(b,0);callbacks[1]();assert.equal(b,1);assert.equal(framed.getSnapshot(),2);stopB();assert.equal(listeners.size,0);
|
||||||
});
|
});
|
||||||
|
// 上游subscribe先通知再抛错要清掉任务;正常通知回调重入触发状态2,必须等下一帧而非混入状态1当前批。
|
||||||
test('eager failed subscriptions cancel presentation work and reentrant changes wait for the next frame',()=>{
|
test('eager failed subscriptions cancel presentation work and reentrant changes wait for the next frame',()=>{
|
||||||
const c=clock(),broken=createFrameSource({getSnapshot:()=>0,subscribe(notify){notify();throw Error('failed subscribe');}},c);assert.throws(()=>broken.subscribe(()=>assert.fail('failed subscription painted')),/failed subscribe/);assert.equal(c.size,0);
|
const c=clock(),broken=createFrameSource({getSnapshot:()=>0,subscribe(notify){notify();throw Error('failed subscribe');}},c);assert.throws(()=>broken.subscribe(()=>assert.fail('failed subscription painted')),/failed subscribe/);assert.equal(c.size,0);
|
||||||
let current=0,listener;const source={getSnapshot:()=>current,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>{seen.push(framed.getSnapshot());if(current===1){current=2;listener();}});current=1;listener();c.step();assert.deepEqual(seen,[1]);assert.equal(c.size,1);c.step();assert.deepEqual(seen,[1,2]);stop();
|
let current=0,listener;const source={getSnapshot:()=>current,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>{seen.push(framed.getSnapshot());if(current===1){current=2;listener();}});current=1;listener();c.step();assert.deepEqual(seen,[1]);assert.equal(c.size,1);c.step();assert.deepEqual(seen,[1,2]);stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 后台值1尚待帧时输入值2以immediate通知,直接发布2并取消旧帧;随后值3仍按普通帧更新。
|
||||||
test('an immediate input invalidation publishes the newest Root and consumes any pending background frame',()=>{
|
test('an immediate input invalidation publishes the newest Root and consumes any pending background frame',()=>{
|
||||||
const c=clock();let value=0,listener;const source={getSnapshot:()=>value,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>seen.push(framed.getSnapshot()));value=1;listener();assert.equal(c.size,1);value=2;listener(true);assert.deepEqual(seen,[2]);assert.equal(c.size,0);c.step();assert.deepEqual(seen,[2]);value=3;listener();c.step();assert.deepEqual(seen,[2,3]);stop();
|
const c=clock();let value=0,listener;const source={getSnapshot:()=>value,subscribe(fn){listener=fn;return()=>{listener=undefined;};}},framed=createFrameSource(source,c),seen=[],stop=framed.subscribe(()=>seen.push(framed.getSnapshot()));value=1;listener();assert.equal(c.size,1);value=2;listener(true);assert.deepEqual(seen,[2]);assert.equal(c.size,0);c.step();assert.deepEqual(seen,[2]);value=3;listener();c.step();assert.deepEqual(seen,[2,3]);stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* bindings几何投影缓存测试。用缩放子图与跨层曲线比较缓存投影和直接投影,按计算次数验证局部失效。
|
||||||
|
* 统计用于确定性工作量检查,不是浏览器帧率基准;缓存不能把旧查询、旧生命周期或可变预览当成不变输入。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {createGeometryProjectionCache} from '../../src/lib/canvas-bindings/geometry-cache.ts';
|
import {createGeometryProjectionCache} from '../../src/lib/canvas-bindings/geometry-cache.ts';
|
||||||
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
||||||
|
// 最小节点/连线工厂保留owner和可选path,以同一registry构造对照。
|
||||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner,path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:source},target:{elementId:target},path});
|
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner,path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:source},target:{elementId:target},path});
|
||||||
|
// P含缩放入口F,S含A/B;in边归P带路径,local边归S,祖先变化应影响不同几何缓存项。
|
||||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',presentation:{contentScale:2,unitScale:.75}}},node('A','S'),node('B','S'),edge('in','C','A','P',{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:500,y:200},in:{x:10,y:0},out:{x:20,y:0}}]}),edge('local','A','B','S')]);
|
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',presentation:{contentScale:2,unitScale:.75}}},node('A','S'),node('B','S'),edge('in','C','A','P',{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:500,y:200},in:{x:10,y:0},out:{x:20,y:0}}]}),edge('local','A','B','S')]);
|
||||||
|
|
||||||
|
// 每次draw与直接投影深比较;标题、选择与元数据不增计算,A局部变化只重算自身,F移动再影响后代及路径。
|
||||||
test('indexed renderer projection preserves legacy output and avoids unrelated geometry work on title, selection and ACK',()=>{
|
test('indexed renderer projection preserves legacy output and avoids unrelated geometry work on title, selection and ACK',()=>{
|
||||||
let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`,runtime:0}),cache=createGeometryProjectionCache(),owner={},selected=new Set(),draw=()=>{const doc=editor.getSnapshot().document,actual=projectCanvas(doc,'P',registry,selected,()=>undefined,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});assert.deepEqual(actual,projectCanvas(doc,'P',registry,selected,()=>undefined));return actual;};
|
let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`,runtime:0}),cache=createGeometryProjectionCache(),owner={},selected=new Set(),draw=()=>{const doc=editor.getSnapshot().document,actual=projectCanvas(doc,'P',registry,selected,()=>undefined,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});assert.deepEqual(actual,projectCanvas(doc,'P',registry,selected,()=>undefined));return actual;};
|
||||||
draw();const before=cache.stats();assert.deepEqual({nodes:before.nodesComputed,children:before.childrenComputed,edges:before.edgesComputed},{nodes:4,children:1,edges:2});editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'only title'}});draw();assert.deepEqual(cache.stats(),before);selected.add('B');draw();assert.deepEqual(cache.stats(),before);editor.updateRuntime(()=>1);draw();assert.deepEqual(cache.stats(),before);
|
draw();const before=cache.stats();assert.deepEqual({nodes:before.nodesComputed,children:before.childrenComputed,edges:before.edgesComputed},{nodes:4,children:1,edges:2});editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'only title'}});draw();assert.deepEqual(cache.stats(),before);selected.add('B');draw();assert.deepEqual(cache.stats(),before);editor.updateRuntime(()=>1);draw();assert.deepEqual(cache.stats(),before);
|
||||||
@@ -13,11 +20,13 @@ test('indexed renderer projection preserves legacy output and avoids unrelated g
|
|||||||
editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+4);assert.equal(cache.stats().childrenComputed,before.childrenComputed+1);assert.equal(cache.stats().edgesComputed,before.edgesComputed+1);
|
editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});draw();assert.equal(cache.stats().nodesComputed,before.nodesComputed+4);assert.equal(cache.stats().childrenComputed,before.childrenComputed+1);assert.equal(cache.stats().edgesComputed,before.edgesComputed+1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 折叠清不可见缓存,旧query拒绝;预览进入/退出各重算,删除恢复incarnation=2后重新出现,不能沿用旧记录身份。
|
||||||
test('geometry cache prunes collapsed/deleted data, respects previews/lifecycles and rejects stale query snapshots',()=>{
|
test('geometry cache prunes collapsed/deleted data, respects previews/lifecycles and rejects stale query snapshots',()=>{
|
||||||
let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`}),cache=createGeometryProjectionCache(),owner={},draw=(geometry=()=>undefined)=>{const doc=editor.getSnapshot().document;return projectCanvas(doc,'P',registry,new Set(),geometry,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});};draw();const old=editor.getQueries();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{contentScale:2,unitScale:.75,collapsed:true}}});draw();assert.equal(cache.stats().nodes,2);assert.equal(cache.stats().children,0);assert.equal(cache.stats().edges,1);assert.throws(()=>projectCanvas(editor.getSnapshot().document,'P',registry,new Set(),()=>undefined,null,undefined,{queries:old}),/stale_query_snapshot/);
|
let id=0;const editor=createEditor({document,registry,createId:()=>`operation-${++id}`}),cache=createGeometryProjectionCache(),owner={},draw=(geometry=()=>undefined)=>{const doc=editor.getSnapshot().document;return projectCanvas(doc,'P',registry,new Set(),geometry,null,undefined,{queries:editor.getQueries(doc),geometry:cache,owner});};draw();const old=editor.getQueries();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{contentScale:2,unitScale:.75,collapsed:true}}});draw();assert.equal(cache.stats().nodes,2);assert.equal(cache.stats().children,0);assert.equal(cache.stats().edges,1);assert.throws(()=>projectCanvas(editor.getSnapshot().document,'P',registry,new Set(),()=>undefined,null,undefined,{queries:old}),/stale_query_snapshot/);
|
||||||
editor.history.undo();draw();assert.equal(cache.stats().nodes,4);const before=cache.stats().nodesComputed,preview={...editor.getSnapshot().document.elements.get('A').geometry,x:900};draw(id=>id==='A'?preview:undefined);assert.equal(cache.stats().nodesComputed,before+1);draw();assert.equal(cache.stats().nodesComputed,before+2);editor.dispatch({type:'element.remove',target:{id:'A',incarnation:1}});draw();assert.equal(cache.stats().nodes,3);editor.history.undo();draw();assert.equal(cache.stats().nodes,4);assert.equal(editor.getSnapshot().document.elements.get('A').incarnation,2);
|
editor.history.undo();draw();assert.equal(cache.stats().nodes,4);const before=cache.stats().nodesComputed,preview={...editor.getSnapshot().document.elements.get('A').geometry,x:900};draw(id=>id==='A'?preview:undefined);assert.equal(cache.stats().nodesComputed,before+1);draw();assert.equal(cache.stats().nodesComputed,before+2);editor.dispatch({type:'element.remove',target:{id:'A',incarnation:1}});draw();assert.equal(cache.stats().nodes,3);editor.history.undo();draw();assert.equal(cache.stats().nodes,4);assert.equal(editor.getSnapshot().document.elements.get('A').incarnation,2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 原path后来修改不改变first结果,新输入按scale=2/x偏移1得到61;输出深冻结,换owner清旧边缓存。
|
||||||
test('path cache snapshots mutable preview inputs and geometry cache cannot be corrupted through returned values',()=>{
|
test('path cache snapshots mutable preview inputs and geometry cache cannot be corrupted through returned values',()=>{
|
||||||
const cache=createGeometryProjectionCache(),owner={},t={x:1,y:2,scale:2,rotation:0},path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:0,y:0},out:{x:0,y:0}}]};cache.begin(owner,'project','P');const first=cache.edge('e',1,path,t);path.knots[0].position.x=30;const second=cache.edge('e',1,path,t);assert.notEqual(first,second);assert.equal(second.knots[0].position.x,61);assert.equal(first.knots[0].position.x,21);assert.ok(Object.isFrozen(second.knots[0].position));cache.end();cache.begin({},'project','P');cache.node('A',1,node('A').geometry,t);cache.end();assert.equal(cache.stats().edges,0);
|
const cache=createGeometryProjectionCache(),owner={},t={x:1,y:2,scale:2,rotation:0},path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:0,y:0},out:{x:0,y:0}}]};cache.begin(owner,'project','P');const first=cache.edge('e',1,path,t);path.knots[0].position.x=30;const second=cache.edge('e',1,path,t);assert.notEqual(first,second);assert.equal(second.knots[0].position.x,61);assert.equal(first.knots[0].position.x,21);assert.ok(Object.isFrozen(second.knots[0].position));cache.end();cache.begin({},'project','P');cache.node('A',1,node('A').geometry,t);cache.end();assert.equal(cache.stats().edges,0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 手势坐标上下文与目标可见性的回归。三层canvas加独立子视图,验证祖先位置/缩放/身份变化何时取消预览。
|
||||||
|
* 同时检查owner链读取缓存与未知祖先数据隔离;这里没有DOM指针捕获,测试的是core会话及placement读取器。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createGesturePlacementReader} from '../../src/lib/canvas-core/gesture-placement.ts';
|
import {createGesturePlacementReader} from '../../src/lib/canvas-core/gesture-placement.ts';
|
||||||
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
|
||||||
|
/** 合法100×100节点工厂,可指定owner和frame组件,用来搭建坐标链。 */
|
||||||
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||||
|
/** 子canvas局部空间中的单个曲线点,连接手势用它验证owner祖先变化会失效路径预览。 */
|
||||||
const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:-10,y:0},out:{x:10,y:0}}]};
|
const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:-10,y:0},out:{x:10,y:0}}]};
|
||||||
|
/** root→child→grand由F/G入口连接,A/D及其边归child,B归grand;同时挂root与child-view比较坐标边界。 */
|
||||||
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}},{id:'grand',parent:{canvasId:'child',entryElementId:'G'}}],[node('F','root','frame'),node('G','child','frame'),node('A','child'),node('B','grand'),node('D','child'),node('C'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'child',source:{elementId:'A'},target:{elementId:'D'},path,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor);sessions.attach('v','root');sessions.attach('child-view','child');return{editor,sessions};}
|
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}},{id:'grand',parent:{canvasId:'child',entryElementId:'G'}}],[node('F','root','frame'),node('G','child','frame'),node('A','child'),node('B','grand'),node('D','child'),node('C'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'child',source:{elementId:'A'},target:{elementId:'D'},path,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor);sessions.attach('v','root');sessions.attach('child-view','child');return{editor,sessions};}
|
||||||
|
// 根视图预览B时移动祖先F,手势先取消;迟到commit拒绝且B的持久几何仍为0。
|
||||||
test('moving an ancestor cancels a descendant preview before a late commit can use old coordinates',()=>{const{editor,sessions}=fixture();assert.equal(sessions.begin('v',[{id:'B',incarnation:1}]).enabled,true);sessions.update('v',{id:'B',incarnation:1},{x:50});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);const before=editor.getSnapshot();assert.equal(sessions.commit('v').status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(before.document.elements.get('B').geometry.x,0);});
|
test('moving an ancestor cancels a descendant preview before a late commit can use old coordinates',()=>{const{editor,sessions}=fixture();assert.equal(sessions.begin('v',[{id:'B',incarnation:1}]).enabled,true);sessions.update('v',{id:'B',incarnation:1},{x:50});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);const before=editor.getSnapshot();assert.equal(sessions.commit('v').status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(before.document.elements.get('B').geometry.x,0);});
|
||||||
|
// 折叠F隐藏A,独立child-view也不能编辑根节点C;分别断言target_hidden与target_outside_view。
|
||||||
test('collapsed and outside-view targets cannot acquire a gesture',()=>{const{editor,sessions}=fixture();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'target_hidden');assert.equal(sessions.begin('child-view',[{id:'C',incarnation:1}]).code,'target_outside_view');});
|
test('collapsed and outside-view targets cannot acquire a gesture',()=>{const{editor,sessions}=fixture();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'target_hidden');assert.equal(sessions.begin('child-view',[{id:'C',incarnation:1}]).code,'target_outside_view');});
|
||||||
|
// 对child局部路径预览后移动F,连接手势必须取消,不能把旧路径坐标提交到新上下文。
|
||||||
test('moving a path owner ancestor cancels its connection preview',()=>{const{editor,sessions}=fixture();assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).enabled,true);sessions.updateConnection('v',{...path,knots:[]});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().connectionGesture,null);});
|
test('moving a path owner ancestor cancels its connection preview',()=>{const{editor,sessions}=fixture();assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).enabled,true);sessions.updateConnection('v',{...path,knots:[]});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().connectionGesture,null);});
|
||||||
|
|
||||||
|
// 分别改contentScale/unitScale/collapsed要求坐标失效;仅标签/标题或无关C几何改变则保留同一gesture对象。
|
||||||
test('scale and collapse cancel active descendant gestures but presentation labels do not',()=>{for(const presentation of [{contentScale:2},{unitScale:2},{collapsed:true}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);sessions.update('v',{id:'B',incarnation:1},{x:50});editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation}});assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'coordinate_context_changed');}const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'another title',presentation:{labelMode:'hidden',contentScale:1,unitScale:1}}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);editor.dispatch({type:'element.geometry',target:{id:'C',incarnation:1},patch:{x:80}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);});
|
test('scale and collapse cancel active descendant gestures but presentation labels do not',()=>{for(const presentation of [{contentScale:2},{unitScale:2},{collapsed:true}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);sessions.update('v',{id:'B',incarnation:1},{x:50});editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation}});assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'coordinate_context_changed');}const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'another title',presentation:{labelMode:'hidden',contentScale:1,unitScale:1}}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);editor.dispatch({type:'element.geometry',target:{id:'C',incarnation:1},patch:{x:80}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);});
|
||||||
|
// child-view以child为坐标根,上方F移动不影响本地B预览,commit仍可合法写x=50。
|
||||||
test('ancestor changes above an independently opened view do not alter its local coordinate space',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);sessions.update('child-view',{id:'B',incarnation:1},{x:50});const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);assert.equal(sessions.commit('child-view').status,'applied');assert.equal(editor.getSnapshot().document.elements.get('B').geometry.x,50);});
|
test('ancestor changes above an independently opened view do not alter its local coordinate space',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);sessions.update('child-view',{id:'B',incarnation:1},{x:50});const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);assert.equal(sessions.commit('child-view').status,'applied');assert.equal(editor.getSnapshot().document.elements.get('B').geometry.x,50);});
|
||||||
|
// 祖先生命周期、未来版本或同几何换owner都改变上下文;即使像素位置看似一致,也必须取消旧手势。
|
||||||
test('ancestor restore, future version and equal-geometry reparent invalidate the old context',()=>{for(const patch of [{incarnation:2},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='F'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}const{editor,sessions}=fixture();editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('H','root','frame'),geometry:{...node('H').geometry,x:-24,y:-36}}});tx.dispatch({type:'canvas.create',canvas:{id:'sibling',parent:{canvasId:'root',entryElementId:'H'}}});});sessions.begin('v',[{id:'B',incarnation:1}]);assert.equal(editor.dispatch({type:'element.move',target:{id:'F',incarnation:1},canvasId:'sibling',geometry:node('F').geometry}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);});
|
test('ancestor restore, future version and equal-geometry reparent invalidate the old context',()=>{for(const patch of [{incarnation:2},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='F'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}const{editor,sessions}=fixture();editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('H','root','frame'),geometry:{...node('H').geometry,x:-24,y:-36}}});tx.dispatch({type:'canvas.create',canvas:{id:'sibling',parent:{canvasId:'root',entryElementId:'H'}}});});sessions.begin('v',[{id:'B',incarnation:1}]);assert.equal(editor.dispatch({type:'element.move',target:{id:'F',incarnation:1},canvasId:'sibling',geometry:node('F').geometry}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);});
|
||||||
|
// 目标A由text变plain-text后旧预览取消,理由target_changed,不能只凭相同ID保留租约。
|
||||||
test('direct target component changes cannot retain an old geometry lease',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,componentId:'plain-text'}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'target_changed');});
|
test('direct target component changes cannot retain an old geometry lease',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,componentId:'plain-text'}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'target_changed');});
|
||||||
|
// 删除F使child-view回退root,订阅只收到一次同时包含视图变更与手势取消的快照。
|
||||||
test('deleting the viewed canvas publishes its root fallback and cancellation together',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('child-view').canvasId,'root');assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'canvas_changed');});
|
test('deleting the viewed canvas publishes its root fallback and cancellation together',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('child-view').canvasId,'root');assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'canvas_changed');});
|
||||||
|
|
||||||
|
|
||||||
|
// 用禁止遍历的表包络读取grand链,1000次复用同一结果且仅两次元素get,验证按链而非全图扫描。
|
||||||
test('placement readers share a checked owner chain and never enumerate the project',()=>{const{editor}=fixture(),document=editor.getSnapshot().document;let reads=0;const protectedDocument={...document,canvases:{has:id=>document.canvases.has(id),get:id=>document.canvases.get(id),[Symbol.iterator](){throw Error('project enumeration');}},elements:{get(id){reads++;return document.elements.get(id);},[Symbol.iterator](){throw Error('project enumeration');}}},read=createGesturePlacementReader(protectedDocument,'root',editor.registry),first=read('grand');assert.equal(first.length,2);for(let i=0;i<1000;i++)assert.strictEqual(read('grand'),first);assert.equal(reads,2);});
|
test('placement readers share a checked owner chain and never enumerate the project',()=>{const{editor}=fixture(),document=editor.getSnapshot().document;let reads=0;const protectedDocument={...document,canvases:{has:id=>document.canvases.has(id),get:id=>document.canvases.get(id),[Symbol.iterator](){throw Error('project enumeration');}},elements:{get(id){reads++;return document.elements.get(id);},[Symbol.iterator](){throw Error('project enumeration');}}},read=createGesturePlacementReader(protectedDocument,'root',editor.registry),first=read('grand');assert.equal(first.length,2);for(let i=0;i<1000;i++)assert.strictEqual(read('grand'),first);assert.equal(reads,2);});
|
||||||
|
// 未来版本F的data getter一读就抛错;placement应以target_hidden拒绝且getter读取数为0。
|
||||||
test('future ancestor data is never interpreted to decide visibility',()=>{const{editor}=fixture(),document=editor.getSnapshot().document,frame=document.elements.get('F');let inspected=0;const opaque={...frame,dataVersion:99,get data(){inspected++;throw Error('opaque payload read');}},elements=new Map(document.elements);elements.set('F',opaque);const read=createGesturePlacementReader({...document,elements},'root',editor.registry);assert.throws(()=>read('grand'),/target_hidden/);assert.equal(inspected,0);});
|
test('future ancestor data is never interpreted to decide visibility',()=>{const{editor}=fixture(),document=editor.getSnapshot().document,frame=document.elements.get('F');let inspected=0;const opaque={...frame,dataVersion:99,get data(){inspected++;throw Error('opaque payload read');}},elements=new Map(document.elements);elements.set('F',opaque);const read=createGesturePlacementReader({...document,elements},'root',editor.registry);assert.throws(()=>read('grand'),/target_hidden/);assert.equal(inspected,0);});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 历史栈变更证据的回放与恢复测试。验证每个transition可正反应用、只存增量范围,并在本地恢复/冲突方案中保留可验证顺序。
|
||||||
|
* 内存storage及固定拒绝的persistence制造可重复的冲突环境,不代表真实磁盘崩溃或服务端集成验证。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createEditor,createDocument,createRegistry,builtinComponents,applyHistoryTransition,validateHistoryTransition,historyTransitionChanges,describeHistoryTransition,readMap} from '../../src/lib/canvas-core/index.ts';
|
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 {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
||||||
import {replayHistoryEvidence,sameHistory,validateHistoryReplay} from '../../src/lib/canvas-runtime/history-replay.ts';
|
import {replayHistoryEvidence,sameHistory,validateHistoryReplay} from '../../src/lib/canvas-runtime/history-replay.ts';
|
||||||
import {resourcePins} from '../../src/lib/canvas-runtime/resources.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]);
|
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))});
|
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);
|
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',()=>{
|
test('history transition evidence exactly rewinds and replays typing, undo/redo, redo invalidation and capacity eviction',()=>{
|
||||||
const editor=createEditor({registry,document,historyLimit:2}),events=[];
|
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);}
|
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'));
|
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));
|
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',()=>{
|
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);
|
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',()=>{
|
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;
|
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/);
|
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/);
|
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',()=>{
|
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);
|
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(){
|
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(){}};
|
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 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)};
|
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()=>{
|
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 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 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();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 用不会联网的transport只测试compile,服务操作仅有changes/id,不发送本地historyTransition。
|
||||||
test('local transition evidence does not enter the protocol 2 service payload',()=>{
|
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);
|
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',()=>{
|
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:{}}]));
|
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);
|
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);
|
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()=>{
|
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 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/);
|
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',()=>{
|
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 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));
|
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',()=>{
|
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;
|
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));
|
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()=>{
|
test('adoption evidence composes the anchor and replacement events into the exact planned history and survives decoding',async()=>{
|
||||||
for(const geometry of [false,true]){
|
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);
|
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',()=>{
|
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));}
|
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()=>{
|
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();}
|
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()=>{
|
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();}
|
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',()=>{
|
test('resource scans reuse validated immutable transition ranges without skipping caller validation',()=>{
|
||||||
const editor=createEditor({registry,document});edit(editor,'cached');
|
const editor=createEditor({registry,document});edit(editor,'cached');
|
||||||
const transition=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
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/);
|
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',()=>{
|
test('mutable, shallow frozen and accessor transition evidence remains live across resource scans',()=>{
|
||||||
const editor=createEditor({registry,document});edit(editor,'source');
|
const editor=createEditor({registry,document});edit(editor,'source');
|
||||||
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
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');
|
assert.equal(historyTransitionChanges(accessor)[0].after,'getter changed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 非枚举隐藏循环和稀疏数组不能递归死循环,也不能取得可复用的范围缓存资格。
|
||||||
test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{
|
test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{
|
||||||
const editor=createEditor({registry,document});edit(editor,'source');
|
const editor=createEditor({registry,document});edit(editor,'source');
|
||||||
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 模型与投影命中的几何/预算回归。覆盖自定义形状、屏幕像素容差、嵌套坐标、预览、曲线细分和实际锚点适配。
|
||||||
|
* complete、confirmed、possible及diagnostics分别检查;未知或预算耗尽不能伪造确定命中。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,editable} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,editable} from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -9,12 +13,18 @@ import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
|||||||
import {toWorld} from '../../src/lib/canvas-core/coordinates.ts';
|
import {toWorld} from '../../src/lib/canvas-core/coordinates.ts';
|
||||||
import {compatConnector,measuredConnector} from '../../src/lib/canvas-bindings/connectors.ts';
|
import {compatConnector,measuredConnector} from '../../src/lib/canvas-bindings/connectors.ts';
|
||||||
const registry=createRegistry(builtinComponents());
|
const registry=createRegistry(builtinComponents());
|
||||||
|
/** 可指定owner/几何/component/data的节点工厂,保持通用包络合法以聚焦几何行为。 */
|
||||||
const node=(id,owner='P',geometry={},componentId='text',data={})=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'',...data},geometry:{x:0,y:0,width:100,height:100,rotation:0,...geometry}});
|
const node=(id,owner='P',geometry={},componentId='text',data={})=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'',...data},geometry:{x:0,y:0,width:100,height:100,rotation:0,...geometry}});
|
||||||
|
/** 连线工厂允许owner与自定义path,端点默认不含port,由适配服务补锚点。 */
|
||||||
const edge=(id,source,target,owner='P',path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source},target:{elementId:target},data:{},path});
|
const edge=(id,source,target,owner='P',path=null)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source},target:{elementId:target},data:{},path});
|
||||||
|
/** 单canvas P的最小文档,嵌套用例单独构造树。 */
|
||||||
const doc=elements=>createDocument('project',[{id:'P',parent:null}],elements);
|
const doc=elements=>createDocument('project',[{id:'P',parent:null}],elements);
|
||||||
|
/** 直接投影指定canvas,可注入预览几何,避免依赖React渲染。 */
|
||||||
const draw=(document,r=registry,canvas='P',preview=()=>undefined)=>projectCanvas(document,canvas,r,new Set(),preview);
|
const draw=(document,r=registry,canvas='P',preview=()=>undefined)=>projectCanvas(document,canvas,r,new Set(),preview);
|
||||||
|
/** 按该投影文档建query并使用固定view身份,默认scene空间/zoom=1,可覆盖屏幕viewport、services及预算。 */
|
||||||
const query=(projection,r=registry,point={x:50,y:50},options={},services={},budget={})=>hitProjectedCanvas(projection,createDocumentQueries(projection.document,r),r,'view',{space:'scene',point,zoom:1,...options},services,budget);
|
const query=(projection,r=registry,point={x:50,y:50},options={},services={},budget={})=>hitProjectedCanvas(projection,createDocumentQueries(projection.document,r),r,'view',{space:'scene',point,zoom:1,...options},services,budget);
|
||||||
|
|
||||||
|
// queryBudget在editor创建时捕获冻结,外部后改不影响;编辑/undo沿用配置,非法预算和中途换预算拒绝。
|
||||||
test('grid configuration is captured per editor and retained through commits and history',()=>{
|
test('grid configuration is captured per editor and retained through commits and history',()=>{
|
||||||
const configuration={cellSize:32,maxElementCells:4,maxQueryCells:2},document=doc([node('A')]);let next=0;const editor=createEditor({document,registry,queryBudget:configuration,createId:()=>`op${++next}`});configuration.cellSize=1000;
|
const configuration={cellSize:32,maxElementCells:4,maxQueryCells:2},document=doc([node('A')]);let next=0;const editor=createEditor({document,registry,queryBudget:configuration,createId:()=>`op${++next}`});configuration.cellSize=1000;
|
||||||
assert.deepEqual(editor.getQueries().budget,{cellSize:32,maxElementCells:4,maxQueryCells:2});assert.ok(Object.isFrozen(editor.getQueries().budget));assert.ok(editor.getQueries().spatial.get('P').oversized.has('A'));assert.deepEqual(editor.getQueries().hitNodes('P',{x:50,y:50}).ids,['A']);editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:300}});assert.equal(editor.getQueries().budget.cellSize,32);editor.history.undo();assert.deepEqual(editor.getQueries().hitNodes('P',{x:50,y:50}).ids,['A']);
|
assert.deepEqual(editor.getQueries().budget,{cellSize:32,maxElementCells:4,maxQueryCells:2});assert.ok(Object.isFrozen(editor.getQueries().budget));assert.ok(editor.getQueries().spatial.get('P').oversized.has('A'));assert.deepEqual(editor.getQueries().hitNodes('P',{x:50,y:50}).ids,['A']);editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:300}});assert.equal(editor.getQueries().budget.cellSize,32);editor.history.undo();assert.deepEqual(editor.getQueries().hitNodes('P',{x:50,y:50}).ids,['A']);
|
||||||
@@ -22,6 +32,7 @@ test('grid configuration is captured per editor and retained through commits and
|
|||||||
assert.throws(()=>advanceDocumentQueries(editor.getQueries(),editor.getSnapshot().document,registry,editor.getQueries().revision,undefined,{cellSize:64}),/query_budget_changed/);
|
assert.throws(()=>advanceDocumentQueries(editor.getQueries(),editor.getSnapshot().document,registry,editor.getQueries().revision,undefined,{cellSize:64}),/query_budget_changed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 自定义圆形可命中逻辑矩形外,radius变化失效而title不失效;registry捕获依赖,非法协议/响应给明确拒绝或不完整结果。
|
||||||
test('component geometry extends hit bounds and invalidates only declared data dependencies',()=>{
|
test('component geometry extends hit bounds and invalidates only declared data dependencies',()=>{
|
||||||
const geometry={kind:'node',version:1,dataDependencies:['radius'],bounds:({geometry:g,element})=>({x:g.x-element.data.radius,y:g.y-element.data.radius,width:2*element.data.radius,height:2*element.data.radius}),hit:({geometry:g,element,point,tolerance})=>Math.hypot(point.x-g.x,point.y-g.y)<=element.data.radius+tolerance},definition={id:'circle',dataVersion:1,structure:'node',features:[editable()],validate:()=>true,copy:{references:[],omit:[]},geometry},r=createRegistry([...builtinComponents(),definition]);geometry.dataDependencies.push('title');assert.deepEqual(r.components.get('circle').geometry.dataDependencies,['radius']);
|
const geometry={kind:'node',version:1,dataDependencies:['radius'],bounds:({geometry:g,element})=>({x:g.x-element.data.radius,y:g.y-element.data.radius,width:2*element.data.radius,height:2*element.data.radius}),hit:({geometry:g,element,point,tolerance})=>Math.hypot(point.x-g.x,point.y-g.y)<=element.data.radius+tolerance},definition={id:'circle',dataVersion:1,structure:'node',features:[editable()],validate:()=>true,copy:{references:[],omit:[]},geometry},r=createRegistry([...builtinComponents(),definition]);geometry.dataDependencies.push('title');assert.deepEqual(r.components.get('circle').geometry.dataDependencies,['radius']);
|
||||||
let next=0;const editor=createEditor({document:doc([node('A','P',{},'circle',{radius:200})]),registry:r,createId:()=>`op${++next}`}),first=editor.getQueries();assert.deepEqual(first.hitNodes('P',{x:-150,y:0}).ids,['A']);assert.deepEqual(first.hitNodeBounds('P',{x:-150,y:0}).ids,[]);
|
let next=0;const editor=createEditor({document:doc([node('A','P',{},'circle',{radius:200})]),registry:r,createId:()=>`op${++next}`}),first=editor.getQueries();assert.deepEqual(first.hitNodes('P',{x:-150,y:0}).ids,['A']);assert.deepEqual(first.hitNodeBounds('P',{x:-150,y:0}).ids,[]);
|
||||||
@@ -30,6 +41,7 @@ test('component geometry extends hit bounds and invalidates only declared data d
|
|||||||
const badRegistry=createRegistry([{...definition,geometry:{...geometry,bounds:()=>({x:0,y:0,width:-1,height:10})}}]),bad=doc([node('A','P',{},'circle',{radius:200})]);assert.deepEqual(createDocumentQueries(bad,badRegistry).hitNodes('P',{x:0,y:0}),{ids:[],complete:false,unavailable:['A']});
|
const badRegistry=createRegistry([{...definition,geometry:{...geometry,bounds:()=>({x:0,y:0,width:-1,height:10})}}]),bad=doc([node('A','P',{},'circle',{radius:200})]);assert.deepEqual(createDocumentQueries(bad,badRegistry).hitNodes('P',{x:0,y:0}),{ids:[],complete:false,unavailable:['A']});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 旋转缩放父子树在0.1/1/4倍屏幕坐标往返只转换一次;displayPath反映所开视图根,像素容差反算随zoom变化。
|
||||||
test('scene query converts screen pixels once and returns nested display path at different zooms',()=>{
|
test('scene query converts screen pixels once and returns nested display path at different zooms',()=>{
|
||||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P',{x:300,y:200,width:500,height:400,rotation:37},'frame',{presentation:{contentScale:2,unitScale:.75}}),node('A','S',{x:100,y:100,width:80,height:60,rotation:21})]),p=draw(document),transform=p.transforms.get('S'),point=toWorld(transform,{x:140,y:130});
|
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P',{x:300,y:200,width:500,height:400,rotation:37},'frame',{presentation:{contentScale:2,unitScale:.75}}),node('A','S',{x:100,y:100,width:80,height:60,rotation:21})]),p=draw(document),transform=p.transforms.get('S'),point=toWorld(transform,{x:140,y:130});
|
||||||
for(const zoom of [.1,1,4]){const viewport={x:70,y:-110,zoom,origin:{x:200,y:60}},result=query(p,registry,{x:point.x*zoom+viewport.x+viewport.origin.x,y:point.y*zoom+viewport.y+viewport.origin.y},{space:'screen',viewport,tolerancePx:0});const hit=result.hits.find(hit=>hit.target.id==='A');assert.ok(hit);assert.equal(hit.ownerCanvasId,'S');assert.deepEqual(hit.displayPath,{viewSessionId:'view',canvasIds:['P','S'],entryIds:['F']});assert.ok(Math.abs(result.pointScene.x-point.x)<1e-8);assert.equal(result.complete,true);assert.ok(Object.isFrozen(hit.displayPath.canvasIds));}
|
for(const zoom of [.1,1,4]){const viewport={x:70,y:-110,zoom,origin:{x:200,y:60}},result=query(p,registry,{x:point.x*zoom+viewport.x+viewport.origin.x,y:point.y*zoom+viewport.y+viewport.origin.y},{space:'screen',viewport,tolerancePx:0});const hit=result.hits.find(hit=>hit.target.id==='A');assert.ok(hit);assert.equal(hit.ownerCanvasId,'S');assert.deepEqual(hit.displayPath,{viewSessionId:'view',canvasIds:['P','S'],entryIds:['F']});assert.ok(Math.abs(result.pointScene.x-point.x)<1e-8);assert.equal(result.complete,true);assert.ok(Object.isFrozen(hit.displayPath.canvasIds));}
|
||||||
@@ -37,13 +49,16 @@ test('scene query converts screen pixels once and returns nested display path at
|
|||||||
const simple=draw(doc([node('A')]));for(const zoom of [.1,1,4]){assert.equal(query(simple,registry,{x:100+5/zoom,y:50},{zoom,tolerancePx:6}).hits.length,1);assert.equal(query(simple,registry,{x:100+7/zoom,y:50},{zoom,tolerancePx:6}).hits.length,0);}
|
const simple=draw(doc([node('A')]));for(const zoom of [.1,1,4]){assert.equal(query(simple,registry,{x:100+5/zoom,y:50},{zoom,tolerancePx:6}).hits.length,1);assert.equal(query(simple,registry,{x:100+7/zoom,y:50},{zoom,tolerancePx:6}).hits.length,0);}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 预览A移到x=5000,显式previewIds补入候选并排除旧位置;registry/文档来源不匹配和退化zoom均拒绝。
|
||||||
test('preview nodes outside committed grid remain hittable; stale provenance is rejected',()=>{
|
test('preview nodes outside committed grid remain hittable; stale provenance is rejected',()=>{
|
||||||
const document=doc([node('A')]),p=draw(document,registry,'P',()=>({...document.elements.get('A').geometry,x:5000}));assert.equal(query(p,registry,{x:5050,y:50}).hits.length,0);assert.equal(query(p,registry,{x:5050,y:50},{},{previewIds:new Set(['A'])}).hits[0].target.id,'A');assert.equal(query(p,registry,{x:50,y:50},{},{previewIds:new Set(['A'])}).hits.length,0);
|
const document=doc([node('A')]),p=draw(document,registry,'P',()=>({...document.elements.get('A').geometry,x:5000}));assert.equal(query(p,registry,{x:5050,y:50}).hits.length,0);assert.equal(query(p,registry,{x:5050,y:50},{},{previewIds:new Set(['A'])}).hits[0].target.id,'A');assert.equal(query(p,registry,{x:50,y:50},{},{previewIds:new Set(['A'])}).hits.length,0);
|
||||||
assert.throws(()=>hitProjectedCanvas(p,createDocumentQueries(document,registry),createRegistry(builtinComponents()),'view',{space:'scene',point:{x:0,y:0},zoom:1}),/query_registry_changed/);assert.throws(()=>projectCanvas(document,'P',createRegistry(builtinComponents()),new Set(),()=>undefined,null,undefined,{queries:createDocumentQueries(document,registry)}),/query_registry_changed/);
|
assert.throws(()=>hitProjectedCanvas(p,createDocumentQueries(document,registry),createRegistry(builtinComponents()),'view',{space:'scene',point:{x:0,y:0},zoom:1}),/query_registry_changed/);assert.throws(()=>projectCanvas(document,'P',createRegistry(builtinComponents()),new Set(),()=>undefined,null,undefined,{queries:createDocumentQueries(document,registry)}),/query_registry_changed/);
|
||||||
const other=doc([node('A')]);assert.throws(()=>hitProjectedCanvas(p,createDocumentQueries(other,registry),registry,'view',{space:'scene',point:{x:0,y:0},zoom:1}),/stale_query_snapshot/);assert.throws(()=>query(p,registry,{x:0,y:0},{zoom:Number.MIN_VALUE}),/invalid_hit_query/);
|
const other=doc([node('A')]);assert.throws(()=>hitProjectedCanvas(p,createDocumentQueries(other,registry),registry,'view',{space:'scene',point:{x:0,y:0},zoom:1}),/stale_query_snapshot/);assert.throws(()=>query(p,registry,{x:0,y:0},{zoom:Number.MIN_VALUE}),/invalid_hit_query/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 按三次贝塞尔多项式正向生成曲线点,作为hitCubic的几何对照输入而非采样碰运气。 */
|
||||||
const cubicPoint=([a,b,c,d],t)=>({x:(1-t)**3*a.x+3*(1-t)**2*t*b.x+3*(1-t)*t*t*c.x+t**3*d.x,y:(1-t)**3*a.y+3*(1-t)**2*t*b.y+3*(1-t)*t*t*c.y+t**3*d.y});
|
const cubicPoint=([a,b,c,d],t)=>({x:(1-t)**3*a.x+3*(1-t)**2*t*b.x+3*(1-t)*t*t*c.x+t**3*d.x,y:(1-t)**3*a.y+3*(1-t)**2*t*b.y+3*(1-t)*t*t*c.y+t**3*d.y});
|
||||||
|
// 正向曲线点应confirmed,弦中点不误命中;一步预算只possible且budget=true,极大坐标不能产NaN距离。
|
||||||
test('bounded cubic refinement hits actual curve, rejects chord and preserves ambiguous/budget outcomes',()=>{
|
test('bounded cubic refinement hits actual curve, rejects chord and preserves ambiguous/budget outcomes',()=>{
|
||||||
const curve=[{x:0,y:0},{x:0,y:200},{x:200,y:200},{x:200,y:0}],options={error:.01,maxDepth:24,maxSteps:10000};for(const t of [.1,.33,.5,.8]){const result=hitCubic(curve,cubicPoint(curve,t),2,options);assert.equal(result.hit,true);assert.equal(result.certainty,'confirmed');}assert.equal(hitCubic(curve,{x:100,y:0},2,options).hit,false);
|
const curve=[{x:0,y:0},{x:0,y:200},{x:200,y:200},{x:200,y:0}],options={error:.01,maxDepth:24,maxSteps:10000};for(const t of [.1,.33,.5,.8]){const result=hitCubic(curve,cubicPoint(curve,t),2,options);assert.equal(result.hit,true);assert.equal(result.certainty,'confirmed');}assert.equal(hitCubic(curve,{x:100,y:0},2,options).hit,false);
|
||||||
const exhausted=hitCubic(curve,{x:100,y:150},1,{...options,maxSteps:1});assert.equal(exhausted.hit,true);assert.equal(exhausted.certainty,'possible');assert.equal(exhausted.budget,true);
|
const exhausted=hitCubic(curve,{x:100,y:150},1,{...options,maxSteps:1});assert.equal(exhausted.hit,true);assert.equal(exhausted.certainty,'possible');assert.equal(exhausted.budget,true);
|
||||||
@@ -51,6 +66,7 @@ test('bounded cubic refinement hits actual curve, rejects chord and preserves am
|
|||||||
const huge=[{x:-1e308,y:0},{x:-1e308,y:1e308},{x:1e308,y:1e308},{x:1e308,y:0}];const result=hitCubic(huge,{x:0,y:7.5e307},1e305,options);assert.equal(result.hit,true);assert.ok(!Number.isNaN(result.distance));
|
const huge=[{x:-1e308,y:0},{x:-1e308,y:1e308},{x:1e308,y:1e308},{x:1e308,y:0}];const result=hitCubic(huge,{x:0,y:7.5e307},1e305,options);assert.equal(result.hit,true);assert.ok(!Number.isNaN(result.distance));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 按显示曲线跨zoom命中,受限细分为possible/不完整;折叠重定向显示端点到F但文档端点仍A。
|
||||||
test('connection query follows projected curve and collapsed endpoints, with explicit unavailable custom geometry',()=>{
|
test('connection query follows projected curve and collapsed endpoints, with explicit unavailable custom geometry',()=>{
|
||||||
const path={type:'bezier-chain',knots:[],startControl:{x:0,y:200},endControl:{x:0,y:200}},document=doc([node('A'),node('B','P',{x:500}),edge('E','A','B','P',path)]),p=draw(document),from=compatConnector(p.nodes[0],undefined,'right'),to=compatConnector(p.nodes[1],undefined,'left'),point=cubicPoint(bezierChainSegments(from,to,path)[0],.5);
|
const path={type:'bezier-chain',knots:[],startControl:{x:0,y:200},endControl:{x:0,y:200}},document=doc([node('A'),node('B','P',{x:500}),edge('E','A','B','P',path)]),p=draw(document),from=compatConnector(p.nodes[0],undefined,'right'),to=compatConnector(p.nodes[1],undefined,'left'),point=cubicPoint(bezierChainSegments(from,to,path)[0],.5);
|
||||||
for(const zoom of [.1,1,4]){const result=query(p,registry,point,{zoom,tolerancePx:6});assert.equal(result.complete,true);assert.equal(result.hits.find(hit=>hit.target.id==='E').certainty,'confirmed');}
|
for(const zoom of [.1,1,4]){const result=query(p,registry,point,{zoom,tolerancePx:6});assert.equal(result.complete,true);assert.equal(result.hits.find(hit=>hit.target.id==='E').certainty,'confirmed');}
|
||||||
@@ -59,11 +75,13 @@ test('connection query follows projected curve and collapsed endpoints, with exp
|
|||||||
const collapsed=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),node('F','P',{x:500},'frame',{presentation:{collapsed:true}}),node('A','S'),edge('E','C','A')]),c=draw(collapsed),f=compatConnector(c.nodes.find(n=>n.id==='C'),undefined,'right'),t=compatConnector(c.nodes.find(n=>n.id==='F'),undefined,'left'),at=cubicPoint(bezierChainSegments(f,t)[0],.5),hit=query(c,registry,at).hits.find(hit=>hit.target.id==='E');assert.deepEqual(hit.displayedEndpoints,{source:'C',target:'F'});assert.equal(hit.ownerCanvasId,'P');assert.equal(collapsed.elements.get('E').target.elementId,'A');
|
const collapsed=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),node('F','P',{x:500},'frame',{presentation:{collapsed:true}}),node('A','S'),edge('E','C','A')]),c=draw(collapsed),f=compatConnector(c.nodes.find(n=>n.id==='C'),undefined,'right'),t=compatConnector(c.nodes.find(n=>n.id==='F'),undefined,'left'),at=cubicPoint(bezierChainSegments(f,t)[0],.5),hit=query(c,registry,at).hits.find(hit=>hit.target.id==='E');assert.deepEqual(hit.displayedEndpoints,{source:'C',target:'F'});assert.equal(hit.ownerCanvasId,'P');assert.equal(collapsed.elements.get('E').target.elementId,'A');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle在外侧边缘求锚点,Loose目标可回退source;不存在的handle或node不捏造边界点。
|
||||||
test('measured native handles use outer edges and loose target fallback without fabricating absent bounds',()=>{
|
test('measured native handles use outer edges and loose target fallback without fabricating absent bounds',()=>{
|
||||||
const node={internals:{positionAbsolute:{x:100,y:200},handleBounds:{source:[{id:'out',position:'right',x:90,y:40,width:10,height:20}],target:null}}};assert.deepEqual(measuredConnector(node,'out','source'),{point:{x:200,y:250},normal:{x:1,y:0}});assert.deepEqual(measuredConnector(node,'out','target'),measuredConnector(node,'out','source'));assert.equal(measuredConnector(node,'absent','target'),undefined);assert.equal(measuredConnector(undefined,null,'source'),undefined);
|
const node={internals:{positionAbsolute:{x:100,y:200},handleBounds:{source:[{id:'out',position:'right',x:90,y:40,width:10,height:20}],target:null}}};assert.deepEqual(measuredConnector(node,'out','source'),{point:{x:200,y:250},normal:{x:1,y:0}});assert.deepEqual(measuredConnector(node,'out','target'),measuredConnector(node,'out','source'));assert.equal(measuredConnector(node,'absent','target'),undefined);assert.equal(measuredConnector(undefined,null,'source'),undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 自定义圆节点与粗线使用模型协议及服务锚点;缺锚点/非法线宽/handler抛错分别报告诊断,opaque path保持原样。
|
||||||
test('registered custom renderers provide model hit shapes, measured anchors, display-space curves and stroke width',()=>{
|
test('registered custom renderers provide model hit shapes, measured anchors, display-space curves and stroke width',()=>{
|
||||||
let context;
|
let context;
|
||||||
const definition={id:'custom',dataVersion:1,structure:'node',features:[],validate:()=>true,geometry:{...rectangleGeometry,hit:({geometry:g,point,tolerance})=>Math.hypot(point.x-g.x-g.width/2,point.y-g.y-g.height/2)<=g.width/2+tolerance}},connection={id:'custom-edge',dataVersion:1,structure:'connection',features:[],validate:()=>true,geometry:{kind:'connection',version:1,strokeWidth:()=>20,segments:input=>{context=input;return[[input.source.point,{x:200,y:200},{x:300,y:200},input.target.point]];}}},r=createRegistry([...builtinComponents(),definition,connection]),catalog=createRendererCatalog([...builtinRendererDefinitions,{componentId:'custom',dataVersion:1,structure:'node',rendererId:'circle'},{componentId:'custom-edge',dataVersion:1,structure:'connection',rendererId:'custom-edge'}]),document=doc([node('A','P',{},'custom'),node('B','P',{x:400},'custom'),{...edge('E','A','B'),componentId:'custom-edge',path:{type:'opaque-custom',value:7}}]),p=projectCanvas(document,'P',r,new Set(),()=>undefined,null,catalog),anchors={anchor:(node,_handle,end)=>({point:{x:node.position.x+(end==='source'?100:0),y:50},normal:{x:end==='source'?1:-1,y:0}})};
|
const definition={id:'custom',dataVersion:1,structure:'node',features:[],validate:()=>true,geometry:{...rectangleGeometry,hit:({geometry:g,point,tolerance})=>Math.hypot(point.x-g.x-g.width/2,point.y-g.y-g.height/2)<=g.width/2+tolerance}},connection={id:'custom-edge',dataVersion:1,structure:'connection',features:[],validate:()=>true,geometry:{kind:'connection',version:1,strokeWidth:()=>20,segments:input=>{context=input;return[[input.source.point,{x:200,y:200},{x:300,y:200},input.target.point]];}}},r=createRegistry([...builtinComponents(),definition,connection]),catalog=createRendererCatalog([...builtinRendererDefinitions,{componentId:'custom',dataVersion:1,structure:'node',rendererId:'circle'},{componentId:'custom-edge',dataVersion:1,structure:'connection',rendererId:'custom-edge'}]),document=doc([node('A','P',{},'custom'),node('B','P',{x:400},'custom'),{...edge('E','A','B'),componentId:'custom-edge',path:{type:'opaque-custom',value:7}}]),p=projectCanvas(document,'P',r,new Set(),()=>undefined,null,catalog),anchors={anchor:(node,_handle,end)=>({point:{x:node.position.x+(end==='source'?100:0),y:50},normal:{x:end==='source'?1:-1,y:0}})};
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 导入源文件退休元数据的不可变更新与身份保护测试。已取消 job 的源文件仍需完整引用扫描才能进入回收。
|
||||||
|
* 这里只验证标记、归属及归档演进,不实际删除文件字节。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
||||||
import {markImportFiles,importFiles,prepareImportFileRetention} from '../../src/lib/canvas-runtime/import-file-retention.ts';
|
import {markImportFiles,importFiles,prepareImportFileRetention} from '../../src/lib/canvas-runtime/import-file-retention.ts';
|
||||||
|
// 建立已取消 native-ndjson 导入任务及空资源表;coverage 默认返回完整且无 pin 的扫描结论。
|
||||||
const job={...createImportJob({id:'job',projectId:'P',sourceFingerprint:'source',parserVersion:1,source:{kind:'native-ndjson',fileId:'source',byteLength:10,targetCanvasId:'P'}}),status:'cancelled'},runtime={imports:new Map([['job',job]]),resources:new Map()},coverage=()=>({complete:true,pins:new Map()});
|
const job={...createImportJob({id:'job',projectId:'P',sourceFingerprint:'source',parserVersion:1,source:{kind:'native-ndjson',fileId:'source',byteLength:10,targetCanvasId:'P'}}),status:'cancelled'},runtime={imports:new Map([['job',job]]),resources:new Map()},coverage=()=>({complete:true,pins:new Map()});
|
||||||
|
// 新增退休标记只生成新 runtime/job 并递增 generation;相同标记是 no-op,重新被引用或扫描不完整时清除退休资格。
|
||||||
test('source retention marks are immutable metadata with monotonic job generations and per-file clearing',()=>{
|
test('source retention marks are immutable metadata with monotonic job generations and per-file clearing',()=>{
|
||||||
const next=markImportFiles(runtime,new Map([['source',{version:1,unreferencedAt:100}]]));assert.notEqual(next,runtime);assert.equal(job.source.retirement,undefined);assert.equal(next.imports.get('job').generation,1);assert.ok(Object.isFrozen(next.imports.get('job').source.retirement));validateImportJob(next.imports.get('job'));assert.equal(markImportFiles(next,new Map([['source',{version:1,unreferencedAt:100}]])),next);
|
const next=markImportFiles(runtime,new Map([['source',{version:1,unreferencedAt:100}]]));assert.notEqual(next,runtime);assert.equal(job.source.retirement,undefined);assert.equal(next.imports.get('job').generation,1);assert.ok(Object.isFrozen(next.imports.get('job').source.retirement));validateImportJob(next.imports.get('job'));assert.equal(markImportFiles(next,new Map([['source',{version:1,unreferencedAt:100}]])),next);
|
||||||
const pinned=prepareImportFileRetention(next,next,()=>({complete:true,pins:new Map([['source',new Set(['export'])]])}),new Set());assert.equal(pinned.imports.get('job').source.retirement,undefined);assert.equal(pinned.imports.get('job').generation,2);assert.equal(next.imports.get('job').source.retirement.unreferencedAt,100);
|
const pinned=prepareImportFileRetention(next,next,()=>({complete:true,pins:new Map([['source',new Set(['export'])]])}),new Set());assert.equal(pinned.imports.get('job').source.retirement,undefined);assert.equal(pinned.imports.get('job').generation,2);assert.equal(next.imports.get('job').source.retirement.unreferencedAt,100);
|
||||||
const incomplete=prepareImportFileRetention(next,next,()=>({complete:false,pins:new Map()}),new Set());assert.equal(incomplete.imports.get('job').source.retirement,undefined);
|
const incomplete=prepareImportFileRetention(next,next,()=>({complete:false,pins:new Map()}),new Set());assert.equal(incomplete.imports.get('job').source.retirement,undefined);
|
||||||
});
|
});
|
||||||
|
// 允许源文件向 previousFiles 归档,但拒绝丢失归属、与资源重名、重复 job 共享身份或回退已 purge 元数据;非法版本/时间也拒绝。
|
||||||
test('source ownership, archive direction and completed purge metadata cannot be silently rewritten',()=>{
|
test('source ownership, archive direction and completed purge metadata cannot be silently rewritten',()=>{
|
||||||
const archived={...job,source:{...job.source,fileId:'next',previousFiles:[{fileId:'source',byteLength:10}]}};assert.equal(importFiles(new Map([['job',archived]])).size,2);assert.equal(prepareImportFileRetention(runtime,{...runtime,imports:new Map([['job',archived]])},coverage,new Set()).imports.get('job'),archived);
|
const archived={...job,source:{...job.source,fileId:'next',previousFiles:[{fileId:'source',byteLength:10}]}};assert.equal(importFiles(new Map([['job',archived]])).size,2);assert.equal(prepareImportFileRetention(runtime,{...runtime,imports:new Map([['job',archived]])},coverage,new Set()).imports.get('job'),archived);
|
||||||
assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,imports:new Map()},coverage,new Set()),/import_file_identity_conflict/);assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,resources:new Map([['source',{}]])},coverage,new Set()),/import_file_identity_conflict/);
|
assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,imports:new Map()},coverage,new Set()),/import_file_identity_conflict/);assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,resources:new Map([['source',{}]])},coverage,new Set()),/import_file_identity_conflict/);
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 键盘输入路由的纯逻辑回归。通过上下文标记和简化closest/composedPath对象,测试输入优先级而不创建真实浏览器DOM。
|
||||||
|
* 回调计数用于确认被文本/IME/浮层认领的事件不会继续触发文档快捷键。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {routeCanvasKey,canvasKeyContext} from '../../src/lib/canvas-bindings/input-router.ts';
|
import {routeCanvasKey,canvasKeyContext} from '../../src/lib/canvas-bindings/input-router.ts';
|
||||||
|
// 逐个设置textEditing/composing/overlay/defaultPrevented,Backspace必须在动作层之前退出,gesture与shortcut调用数都为0。
|
||||||
test('text, IME, overlays and already consumed input never execute document shortcuts',()=>{for(const flags of [{textEditing:true},{composing:true},{overlay:true},{defaultPrevented:true}]){let calls=0;const result=routeCanvasKey({key:'Backspace',...flags},{gesture:()=>{calls++;return true;},shortcut:()=>{calls++;return true;}});assert.equal(calls,0);assert.ok(['text','overlay','unclaimed'].includes(result));}});
|
test('text, IME, overlays and already consumed input never execute document shortcuts',()=>{for(const flags of [{textEditing:true},{composing:true},{overlay:true},{defaultPrevented:true}]){let calls=0;const result=routeCanvasKey({key:'Backspace',...flags},{gesture:()=>{calls++;return true;},shortcut:()=>{calls++;return true;}});assert.equal(calls,0);assert.ok(['text','overlay','unclaimed'].includes(result));}});
|
||||||
|
// 依次让gesture/tool/shortcut成为首个赢家,只运行到认领层;无处理器返回unclaimed。
|
||||||
test('gesture then tool then shortcut claim once; declining handlers fall through',()=>{for(const winner of ['gesture','tool','shortcut']){const calls=[];const handlers=Object.fromEntries(['gesture','tool','shortcut'].map(layer=>[layer,()=>{calls.push(layer);return layer===winner;}]));assert.equal(routeCanvasKey({key:'Escape'},handlers),winner);assert.deepEqual(calls,['gesture','tool','shortcut'].slice(0,['gesture','tool','shortcut'].indexOf(winner)+1));}assert.equal(routeCanvasKey({key:'x'},{}),'unclaimed');});
|
test('gesture then tool then shortcut claim once; declining handlers fall through',()=>{for(const winner of ['gesture','tool','shortcut']){const calls=[];const handlers=Object.fromEntries(['gesture','tool','shortcut'].map(layer=>[layer,()=>{calls.push(layer);return layer===winner;}]));assert.equal(routeCanvasKey({key:'Escape'},handlers),winner);assert.deepEqual(calls,['gesture','tool','shortcut'].slice(0,['gesture','tool','shortcut'].indexOf(winner)+1));}assert.equal(routeCanvasKey({key:'x'},{}),'unclaimed');});
|
||||||
|
|
||||||
|
// 用伪closest模拟select和注册浮层,并传空/无效路径项,确认上下文识别不依赖全局Element。
|
||||||
test('DOM input context recognizes registered overlays and native selects without browser globals',()=>{
|
test('DOM input context recognizes registered overlays and native selects without browser globals',()=>{
|
||||||
const select={closest:selector=>selector.split(',').includes('select')?select:null};
|
const select={closest:selector=>selector.split(',').includes('select')?select:null};
|
||||||
const overlay={closest:selector=>selector.includes('[data-canvas-overlay-view]')?overlay:null};
|
const overlay={closest:selector=>selector.includes('[data-canvas-overlay-view]')?overlay:null};
|
||||||
@@ -10,6 +17,7 @@ test('DOM input context recognizes registered overlays and native selects withou
|
|||||||
assert.deepEqual(canvasKeyContext({target:overlay}),{textEditing:false,overlay:true});
|
assert.deepEqual(canvasKeyContext({target:overlay}),{textEditing:false,overlay:true});
|
||||||
assert.deepEqual(canvasKeyContext({target:null,composedPath:()=>[null,{},42]}),{textEditing:false,overlay:false});
|
assert.deepEqual(canvasKeyContext({target:null,composedPath:()=>[null,{},42]}),{textEditing:false,overlay:false});
|
||||||
});
|
});
|
||||||
|
// 事件target已被Shadow DOM重定向到host,composedPath仍含textarea;Escape必须留给内部文本而非取消画布手势。
|
||||||
test('retargeted shadow events retain their inner text owner and never reach a gesture',()=>{
|
test('retargeted shadow events retain their inner text owner and never reach a gesture',()=>{
|
||||||
const input={closest:selector=>selector.split(',').includes('textarea')?input:null},host={closest:()=>null};
|
const input={closest:selector=>selector.split(',').includes('textarea')?input:null},host={closest:()=>null};
|
||||||
const context=canvasKeyContext({target:host,composedPath:()=>[input,host]});let canceled=false;
|
const context=canvasKeyContext({target:host,composedPath:()=>[input,host]});let canceled=false;
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 恢复日志的结构差分与重放测试。补丁需精确还原 JSON 的可观察顺序和内容,并保持先前状态根不变。
|
||||||
|
* 小修改应避免重复大 payload;非法路径、稀疏数组或超限变化不能被当作有效日志。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {diffJournal,applyJournal} from '../../src/lib/canvas-runtime/journal-delta.ts';
|
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;};
|
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',()=>{
|
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);
|
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);
|
assert.equal({}.x,undefined);
|
||||||
});
|
});
|
||||||
|
// 1000 行大表只改最后一行再追加一行,要求两个补丁总编码小于 1000 字符,并保留冻结账本字节。
|
||||||
test('a small node update and appended table row do not repeat the unchanged large payload',()=>{
|
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 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);
|
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',()=>{
|
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/);
|
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.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);
|
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',()=>{
|
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;};
|
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;}
|
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,7 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* JSON隔离函数的语义与所有权测试。以日期、toJSON、稀疏数组、特殊键及循环等输入核对JSON转换与深冻结。
|
||||||
|
* 所有权证明只认可函数自己产生的结果,不能因调用方freeze或clone就复用内部证明。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {detachJsonData,hasDetachedJsonData} from '../../src/lib/canvas-core/json-data.ts';
|
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',()=>{
|
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}};
|
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);
|
const value=detachJsonData(external);assert.equal(calls,1);assert.equal(hasDetachedJsonData(value),true);assert.equal(hasDetachedJsonData(external),false);
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 操作账本与调度基础结构的单元测试。账本保存依赖、冻结请求和执行证据;队列及堆只决定处理顺序。
|
||||||
|
* 这里直接操作内存账本,验证状态机与压缩规则,不代表网络或持久存储已经完成。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDeque, createMinHeap } from '../../src/lib/canvas-runtime/structures.ts';
|
import { createDeque, createMinHeap } from '../../src/lib/canvas-runtime/structures.ts';
|
||||||
import { createLedger, compressChanges } from '../../src/lib/canvas-runtime/ledger.ts';
|
import { createLedger, compressChanges } from '../../src/lib/canvas-runtime/ledger.ts';
|
||||||
|
// 固定的文本元素快照;完整几何和 incarnation 让字段压缩与身份检查有明确基线。
|
||||||
const node = { id: 'a', incarnation: 1, ownerCanvasId: 'root', componentId: 'text', dataVersion: 1, data: { title: 'a', text: '' }, structure: 'node', geometry: { x: 0, y: 0, width: 20, height: 20, rotation: 0 } };
|
const node = { id: 'a', incarnation: 1, ownerCanvasId: 'root', componentId: 'text', dataVersion: 1, data: { title: 'a', text: '' }, structure: 'node', geometry: { x: 0, y: 0, width: 20, height: 20, rotation: 0 } };
|
||||||
|
// 构造最小文档操作;依赖由各用例显式传入,避免默认依赖掩盖错误。
|
||||||
const intent = (id, changes = [], dependencies = []) => ({ id, operationType: 'document.change', operationSchemaVersion: 1, sequence: 1, origin: 'user', changes, dependencies });
|
const intent = (id, changes = [], dependencies = []) => ({ id, operationType: 'document.change', operationSchemaVersion: 1, sequence: 1, origin: 'user', changes, dependencies });
|
||||||
|
// 把 create 操作冻结成固定请求;后续状态转换必须保留同一请求 ID 和请求体。
|
||||||
const frozen = ledger => ledger.freeze({ id: 'request', operationIds: ['create'], canvasIds: ['root'], expectedVersions: { root: 1 }, payload: { changes: [] } });
|
const frozen = ledger => ledger.freeze({ id: 'request', operationIds: ['create'], canvasIds: ['root'], expectedVersions: { root: 1 }, payload: { changes: [] } });
|
||||||
|
|
||||||
|
// 先验证大量 FIFO 出入队后的顺序,再验证最小堆以截止时间为主、序号为次序,空容器均返回 undefined。
|
||||||
test('FIFO retains order across compaction; retry heap orders deadline then sequence', () => {
|
test('FIFO retains order across compaction; retry heap orders deadline then sequence', () => {
|
||||||
const queue = createDeque(); for (let i = 0; i < 4000; i++) queue.push(i);
|
const queue = createDeque(); for (let i = 0; i < 4000; i++) queue.push(i);
|
||||||
for (let i = 0; i < 4000; i++) assert.equal(queue.pop(), i);
|
for (let i = 0; i < 4000; i++) assert.equal(queue.pop(), i);
|
||||||
@@ -16,6 +24,7 @@ test('FIFO retains order across compaction; retry heap orders deadline then sequ
|
|||||||
assert.equal(heap.pop(), undefined);
|
assert.equal(heap.pop(), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 仅针对完全未发送的修改:改名和移动折叠进创建对象,随后删除可抵消整个创建过程,移动不能覆盖标题。
|
||||||
test('wholly unsent create-data-delete disappears but a position update preserves the title', () => {
|
test('wholly unsent create-data-delete disappears but a position update preserves the title', () => {
|
||||||
const create = { kind: 'element', id: 'a', before: null, after: node };
|
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 rename = { kind: 'field', target: { id: 'a', incarnation: 1 }, field: 'data.title', before: 'a', after: 'renamed', beforePresent: true, afterPresent: true };
|
||||||
@@ -25,6 +34,7 @@ test('wholly unsent create-data-delete disappears but a position update preserve
|
|||||||
assert.deepEqual(compressChanges([...compressed, { kind: 'element', id: 'a', before: compressed[0].after, after: null }]), []);
|
assert.deepEqual(compressChanges([...compressed, { kind: 'element', id: 'a', before: compressed[0].after, after: null }]), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未知和已受理都保留“可能执行”证据并阻塞依赖删除;只有 applied 才放行,重派发仍使用原请求体。
|
||||||
test('frozen identity remains exact after unknown/accepted outcomes and deletion is a separate pending operation', () => {
|
test('frozen identity remains exact after unknown/accepted outcomes and deletion is a separate pending operation', () => {
|
||||||
const ledger = createLedger(); ledger.add(intent('create'), { a: 'absent' });
|
const ledger = createLedger(); ledger.add(intent('create'), { a: 'absent' });
|
||||||
const request = frozen(ledger), body = request.body;
|
const request = frozen(ledger), body = request.body;
|
||||||
@@ -38,6 +48,7 @@ test('frozen identity remains exact after unknown/accepted outcomes and deletion
|
|||||||
ledger.result('request', 'applied'); assert.equal(ledger.eligible('delete'), true);
|
ledger.result('request', 'applied'); assert.equal(ledger.eligible('delete'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 拒绝缺失依赖及同 ID 不同意图;旧代次落盘完成不能把后来新增的操作一并标记为持久化。
|
||||||
test('dependencies are explicit, duplicate identities conflict and late durable completion does not mark newer work durable', () => {
|
test('dependencies are explicit, duplicate identities conflict and late durable completion does not mark newer work durable', () => {
|
||||||
const ledger = createLedger();
|
const ledger = createLedger();
|
||||||
assert.throws(() => ledger.add(intent('child', [], ['missing']), {}), /dependency_missing/);
|
assert.throws(() => ledger.add(intent('child', [], ['missing']), {}), /dependency_missing/);
|
||||||
@@ -48,6 +59,7 @@ test('dependencies are explicit, duplicate identities conflict and late durable
|
|||||||
assert.equal(ledger.eligible('second'), false);
|
assert.equal(ledger.eligible('second'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 恢复后先保留可能执行的风险;明确零写入结果将原操作阻塞,并拒绝再把同一终态改写为成功。
|
||||||
test('recovery retains possible application; explicit terminal zero-write does not pretend dependent writes succeeded', () => {
|
test('recovery retains possible application; explicit terminal zero-write does not pretend dependent writes succeeded', () => {
|
||||||
const original = createLedger(); original.add(intent('create'), {}); frozen(original); original.markDispatch('request');
|
const original = createLedger(); original.add(intent('create'), {}); frozen(original); original.markDispatch('request');
|
||||||
const restored = createLedger(original.getSnapshot());
|
const restored = createLedger(original.getSnapshot());
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 网络准入门的微任务级测试。容量设为1,用手动Promise控制排队顺序,验证拿到名额后仍要核对身份与取消。
|
||||||
|
* 这里不发真实HTTP,也不测试服务端幂等或浏览器网络栈。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createNetworkGate} from '../../src/lib/canvas-runtime/network.ts';
|
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};};
|
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()=>{
|
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);
|
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()=>{
|
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);
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 节点连接点测量的批处理测试。用任务数组代替调度器,验证大量invalidate合并、每挂载owner租约与store隔离。
|
||||||
|
* 10000节点断言是确定性批次数量检查,不是浏览器布局或帧率性能测试。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createNodeInternalsBatch} from '../../src/lib/canvas-bindings/node-internals.ts';
|
import {createNodeInternalsBatch} from '../../src/lib/canvas-bindings/node-internals.ts';
|
||||||
|
// 每个lease重复invalidate,调度仍只有一个任务,执行一次提交完整ID列表;末尾释放所有owner。
|
||||||
test('10000 mounted nodes coalesce repeated measurement requests into one renderer update',()=>{
|
test('10000 mounted nodes coalesce repeated measurement requests into one renderer update',()=>{
|
||||||
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),leases=Array.from({length:10000},(_,i)=>batch.acquire('n'+i));for(const lease of leases){lease.invalidate();lease.invalidate();}assert.equal(tasks.length,1);assert.equal(calls.length,0);tasks.shift()();assert.deepEqual(calls,[Array.from({length:10000},(_,i)=>'n'+i)]);for(const lease of leases)lease.dispose();
|
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),leases=Array.from({length:10000},(_,i)=>batch.acquire('n'+i));for(const lease of leases){lease.invalidate();lease.invalidate();}assert.equal(tasks.length,1);assert.equal(calls.length,0);tasks.shift()();assert.deepEqual(calls,[Array.from({length:10000},(_,i)=>'n'+i)]);for(const lease of leases)lease.dispose();
|
||||||
});
|
});
|
||||||
|
// A有两个owner、B卸载,旧A释放不取消新A的待测量;最后owner释放后迟到任务不能再次调用renderer。
|
||||||
test('unmounted and replaced owners cannot retain or cancel another lease for the same node',()=>{
|
test('unmounted and replaced owners cannot retain or cancel another lease for the same node',()=>{
|
||||||
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),old=batch.acquire('A'),next=batch.acquire('A'),removed=batch.acquire('B');old.invalidate();next.invalidate();removed.invalidate();old.dispose();removed.dispose();old.invalidate();tasks.shift()();assert.deepEqual(calls,[['A']]);next.invalidate();next.dispose();tasks.shift()();assert.equal(calls.length,1);
|
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),old=batch.acquire('A'),next=batch.acquire('A'),removed=batch.acquire('B');old.invalidate();next.invalidate();removed.invalidate();old.dispose();removed.dispose();old.invalidate();tasks.shift()();assert.deepEqual(calls,[['A']]);next.invalidate();next.dispose();tasks.shift()();assert.equal(calls.length,1);
|
||||||
});
|
});
|
||||||
|
// 两个batch可各自更新同名A;one回调重入invalidate只排下一批,不影响two的任务或顺序。
|
||||||
test('renderer stores and later updates remain independent, including reentrant invalidation',()=>{
|
test('renderer stores and later updates remain independent, including reentrant invalidation',()=>{
|
||||||
const tasks=[],calls=[];let again;const one=createNodeInternalsBatch(ids=>{calls.push(['one',ids]);again?.();},fn=>tasks.push(fn)),two=createNodeInternalsBatch(ids=>calls.push(['two',ids]),fn=>tasks.push(fn)),a=one.acquire('A'),b=two.acquire('A');let reentered=false;again=()=>{if(!reentered){reentered=true;a.invalidate();}};a.invalidate();b.invalidate();assert.equal(tasks.length,2);tasks.shift()();assert.equal(tasks.length,2);tasks.shift()();tasks.shift()();assert.deepEqual(calls,[['one',['A']],['two',['A']],['one',['A']]]);a.dispose();b.dispose();
|
const tasks=[],calls=[];let again;const one=createNodeInternalsBatch(ids=>{calls.push(['one',ids]);again?.();},fn=>tasks.push(fn)),two=createNodeInternalsBatch(ids=>calls.push(['two',ids]),fn=>tasks.push(fn)),a=one.acquire('A'),b=two.acquire('A');let reentered=false;again=()=>{if(!reentered){reentered=true;a.invalidate();}};a.invalidate();b.invalidate();assert.equal(tasks.length,2);tasks.shift()();assert.equal(tasks.length,2);tasks.shift()();tasks.shift()();assert.deepEqual(calls,[['one',['A']],['two',['A']],['one',['A']]]);a.dispose();b.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 浮层索引的作用域、可见性与租约测试。纯屏幕矩形代替DOM测量,检查焦点/组合态保留及旧实例清理不会误删替换实例。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {createOverlayIndex} from '../../src/lib/canvas-bindings/overlays.ts';
|
import {test} from 'node:test';import assert from 'node:assert/strict';import {createOverlayIndex} from '../../src/lib/canvas-bindings/overlays.ts';
|
||||||
|
/** 默认title浮层指向a/incarnation,x可移到视口外;viewport固定200×200供可见性比较。 */
|
||||||
const value=(incarnation=1,x=0)=>({placement:'title',target:{id:'a',incarnation},bounds:{x,y:0,width:100,height:30},focused:false,composing:false}),viewport={x:0,y:0,width:200,height:200};
|
const value=(incarnation=1,x=0)=>({placement:'title',target:{id:'a',incarnation},bounds:{x,y:0,width:100,height:30},focused:false,composing:false}),viewport={x:0,y:0,width:200,height:200};
|
||||||
|
// 两个view可用同portal ID而独立;屏幕外普通项隐藏,focused/composing保留,但身份失效仍必须排除。
|
||||||
test('screen placements are scoped by view, reject stale incarnations and preserve focused or composing offscreen editors',()=>{const a=createOverlayIndex('view-a'),b=createOverlayIndex('view-b');a.register('portal',value());b.register('portal',value(2));assert.equal(a.visible(viewport,target=>target.incarnation===1).length,1);assert.equal(b.visible(viewport,target=>target.incarnation===1).length,0);const lease=a.register('portal',value(1,1000));assert.equal(a.visible(viewport,()=>true).length,0);lease.update({...value(1,1000),composing:true});assert.equal(a.visible(viewport,()=>true).length,1);lease.update({...value(1,1000),focused:true});assert.equal(a.visible(viewport,()=>true).length,1);assert.equal(a.visible(viewport,()=>false).length,0);});
|
test('screen placements are scoped by view, reject stale incarnations and preserve focused or composing offscreen editors',()=>{const a=createOverlayIndex('view-a'),b=createOverlayIndex('view-b');a.register('portal',value());b.register('portal',value(2));assert.equal(a.visible(viewport,target=>target.incarnation===1).length,1);assert.equal(b.visible(viewport,target=>target.incarnation===1).length,0);const lease=a.register('portal',value(1,1000));assert.equal(a.visible(viewport,()=>true).length,0);lease.update({...value(1,1000),composing:true});assert.equal(a.visible(viewport,()=>true).length,1);lease.update({...value(1,1000),focused:true});assert.equal(a.visible(viewport,()=>true).length,1);assert.equal(a.visible(viewport,()=>false).length,0);});
|
||||||
|
// 同注册ID替换后旧lease.release/update无权改新项,相同测量也不换快照或通知,只有fresh释放才移除。
|
||||||
test('late old-instance updates or cleanup cannot remove a replacement overlay; unchanged measurements do not notify',()=>{const index=createOverlayIndex('v');let changes=0;const stop=index.subscribe(()=>changes++),old=index.register('same',value()),fresh=index.register('same',value(2));const snapshot=index.getSnapshot(),baseline=changes;old.release();old.update(value());fresh.update(value(2));assert.strictEqual(index.getSnapshot(),snapshot);assert.equal(changes,baseline);fresh.release();assert.equal(index.getSnapshot().length,0);stop();});
|
test('late old-instance updates or cleanup cannot remove a replacement overlay; unchanged measurements do not notify',()=>{const index=createOverlayIndex('v');let changes=0;const stop=index.subscribe(()=>changes++),old=index.register('same',value()),fresh=index.register('same',value(2));const snapshot=index.getSnapshot(),baseline=changes;old.release();old.update(value());fresh.update(value(2));assert.strictEqual(index.getSnapshot(),snapshot);assert.equal(changes,baseline);fresh.release();assert.equal(index.getSnapshot().length,0);stop();});
|
||||||
|
// NaN矩形替换被拒绝,原快照与可见项保留;显式clear才清空。
|
||||||
test('invalid overlay replacement leaves the prior placement and index intact',()=>{const index=createOverlayIndex('v');index.register('x',value());const before=index.getSnapshot();assert.throws(()=>index.register('x',{...value(),bounds:{x:NaN,y:0,width:1,height:1}}));assert.strictEqual(index.getSnapshot(),before);assert.equal(index.visible(viewport,()=>true).length,1);index.clear();assert.equal(index.visible(viewport,()=>true).length,0);});
|
test('invalid overlay replacement leaves the prior placement and index intact',()=>{const index=createOverlayIndex('v');index.register('x',value());const before=index.getSnapshot();assert.throws(()=>index.register('x',{...value(),bounds:{x:NaN,y:0,width:1,height:1}}));assert.strictEqual(index.getSnapshot(),before);assert.equal(index.visible(viewport,()=>true).length,1);index.clear();assert.equal(index.visible(viewport,()=>true).length,0);});
|
||||||
|
|||||||
@@ -1,24 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 节点绘制范围与空间缓存测试。paint范围可以超出逻辑矩形,处理失败须保留unknown;缓存按实际依赖判断,不以ACK对象引用变化强制重算。
|
||||||
|
* 以纯矩形、投影和计数验证索引,不执行浏览器绘制,也不修改文档几何去迎合裁剪。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts';
|
import {test} from 'node:test';import assert from 'node:assert/strict';import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts';
|
||||||
|
/** 默认10×10区域,参数可构造边界接触、移动、巨型覆盖及非法矩形。 */
|
||||||
const region=(id,x,y,width=10,height=10)=>({id,bounds:{x,y,width,height}});
|
const region=(id,x,y,width=10,height=10)=>({id,bounds:{x,y,width,height}});
|
||||||
|
// 10单位网格测试负坐标与点边界,巨型范围保守索引;移动/移除/clear及时失效,原bounds对象不变。
|
||||||
test('paint grid updates moves/deletes, includes boundary and oversized entries without changing model geometry',()=>{const index=createPaintIndex(10,4),a=region('a',-10,-10),huge=region('huge',-1000,-1000,2000,2000);index.sync([a,huge]);assert.deepEqual([...index.query({x:0,y:0,width:0,height:0})].sort(),['a','huge']);index.sync([region('a',100,100),huge]);assert.deepEqual([...index.query({x:0,y:0,width:1,height:1})],['huge']);index.sync([region('a',100,100)]);assert.equal(index.query({x:0,y:0,width:1,height:1}).size,0);assert.equal(index.query({x:-1e9,y:-1e9,width:2e9,height:2e9}).size,1);assert.equal(a.bounds.x,-10);index.clear();assert.equal(index.query(a.bounds).size,0);});
|
test('paint grid updates moves/deletes, includes boundary and oversized entries without changing model geometry',()=>{const index=createPaintIndex(10,4),a=region('a',-10,-10),huge=region('huge',-1000,-1000,2000,2000);index.sync([a,huge]);assert.deepEqual([...index.query({x:0,y:0,width:0,height:0})].sort(),['a','huge']);index.sync([region('a',100,100),huge]);assert.deepEqual([...index.query({x:0,y:0,width:1,height:1})],['huge']);index.sync([region('a',100,100)]);assert.equal(index.query({x:0,y:0,width:1,height:1}).size,0);assert.equal(index.query({x:-1e9,y:-1e9,width:2e9,height:2e9}).size,1);assert.equal(a.bounds.x,-10);index.clear();assert.equal(index.query(a.bounds).size,0);});
|
||||||
|
// 整批含NaN或重复ID必须拒绝,safe保留旧网格位置;无限查询坐标同样拒绝。
|
||||||
test('invalid replacement is rejected before any grid changes',()=>{const index=createPaintIndex();index.sync([region('safe',0,0)]);assert.throws(()=>index.sync([region('safe',50,50),region('bad',NaN,0)]));assert.equal(index.query({x:0,y:0,width:1,height:1}).has('safe'),true);assert.throws(()=>index.sync([region('a',0,0),region('a',1,1)]));assert.throws(()=>index.query({x:Infinity,y:0,width:1,height:1}));});
|
test('invalid replacement is rejected before any grid changes',()=>{const index=createPaintIndex();index.sync([region('safe',0,0)]);assert.throws(()=>index.sync([region('safe',50,50),region('bad',NaN,0)]));assert.equal(index.query({x:0,y:0,width:1,height:1}).has('safe'),true);assert.throws(()=>index.sync([region('a',0,0),region('a',1,1)]));assert.throws(()=>index.query({x:Infinity,y:0,width:1,height:1}));});
|
||||||
import {projectedPaintOverflow} from '../../src/lib/canvas-bindings/projected-paint.ts';
|
import {projectedPaintOverflow} from '../../src/lib/canvas-bindings/projected-paint.ts';
|
||||||
import {createRegistry,createDocument,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
import {createRegistry,createDocument,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
||||||
|
// 自定义bounds按role给更大paint矩形;broken handler进入unknown,成功范围不能倒写模型尺寸。
|
||||||
test('projected paint uses paint role independently of logical bounds and conservatively retains failed handlers',()=>{const text=builtinComponents().find(c=>c.id==='text'),roles=[],geometry={...text.geometry,bounds(context){roles.push(context.role);if(context.element.data.title==='broken')throw Error('broken');return context.role==='paint'?{x:-100,y:-50,width:400,height:200}:{x:0,y:0,width:100,height:100};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),node=(id,title)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title,text:''}}),document=createDocument('p',[{id:'p',parent:null}],[node('a','okay'),node('b','broken')]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),result=projectedPaintOverflow(projection,registry);assert.ok(roles.includes('paint'));assert.deepEqual(result.regions,[{id:'a',bounds:{x:-100,y:-50,width:400,height:200}}]);assert.deepEqual(result.unknown,['b']);assert.equal(document.elements.get('a').geometry.width,100);});
|
test('projected paint uses paint role independently of logical bounds and conservatively retains failed handlers',()=>{const text=builtinComponents().find(c=>c.id==='text'),roles=[],geometry={...text.geometry,bounds(context){roles.push(context.role);if(context.element.data.title==='broken')throw Error('broken');return context.role==='paint'?{x:-100,y:-50,width:400,height:200}:{x:0,y:0,width:100,height:100};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),node=(id,title)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title,text:''}}),document=createDocument('p',[{id:'p',parent:null}],[node('a','okay'),node('b','broken')]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),result=projectedPaintOverflow(projection,registry);assert.ok(roles.includes('paint'));assert.deepEqual(result.regions,[{id:'a',bounds:{x:-100,y:-50,width:400,height:200}}]);assert.deepEqual(result.unknown,['b']);assert.equal(document.elements.get('a').geometry.width,100);});
|
||||||
import {createProjectedPaintIndex} from '../../src/lib/canvas-bindings/projected-paint.ts';
|
import {createProjectedPaintIndex} from '../../src/lib/canvas-bindings/projected-paint.ts';
|
||||||
|
// 同投影复用不增computed,坐标变换变化重算,回原变换仍需重算;节点移除清条目。
|
||||||
test('view paint cache reuses unchanged records, invalidates transform/identity and drops removed rows',()=>{const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:45},data:{title:'a',text:''}},document=createDocument('p',[{id:'p',parent:null}],[node]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();cache.sync(projection,registry);assert.equal(cache.stats().computed,1);cache.sync({...projection},registry);assert.equal(cache.stats().computed,1);const transforms=new Map(projection.transforms),t=transforms.get('p');transforms.set('p',{...t,x:t.x+100});cache.sync({...projection,transforms},registry);assert.equal(cache.stats().computed,2);cache.sync(projection,registry);assert.equal(cache.stats().computed,3);const cleared=cache.sync({...projection,nodes:[]},registry);assert.equal(cache.stats().entries,0);assert.equal(cleared.query({x:-100,y:-100,width:500,height:500}).size,0);cache.clear();});
|
test('view paint cache reuses unchanged records, invalidates transform/identity and drops removed rows',()=>{const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:45},data:{title:'a',text:''}},document=createDocument('p',[{id:'p',parent:null}],[node]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();cache.sync(projection,registry);assert.equal(cache.stats().computed,1);cache.sync({...projection},registry);assert.equal(cache.stats().computed,1);const transforms=new Map(projection.transforms),t=transforms.get('p');transforms.set('p',{...t,x:t.x+100});cache.sync({...projection,transforms},registry);assert.equal(cache.stats().computed,2);cache.sync(projection,registry);assert.equal(cache.stats().computed,3);const cleared=cache.sync({...projection,nodes:[]},registry);assert.equal(cache.stats().entries,0);assert.equal(cleared.query({x:-100,y:-100,width:500,height:500}).size,0);cache.clear();});
|
||||||
|
// 仅声明radius依赖,深相等ACK/改title不重算;radius或incarnation改变才失效,计数同时覆盖上一轮差异还原。
|
||||||
test('paint invalidation follows declared data dependencies instead of ACK record identities or unrelated titles',()=>{let calls=0;const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:['radius'],bounds({element,geometry:g}){calls++;return{x:g.x-element.data.radius,y:g.y,width:g.width+2*element.data.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),nodes=Array.from({length:40},(_,i)=>({id:'n'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'n'+i,text:'',radius:10}})),cache=createProjectedPaintIndex(),sync=records=>cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);sync(nodes);assert.equal(calls,40);sync(structuredClone(nodes));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,title:'only title'}}:n));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,radius:25}}:n));assert.equal(calls,41);sync(nodes.map((n,i)=>i===1?{...n,incarnation:2}:n));assert.equal(calls,43);});
|
test('paint invalidation follows declared data dependencies instead of ACK record identities or unrelated titles',()=>{let calls=0;const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:['radius'],bounds({element,geometry:g}){calls++;return{x:g.x-element.data.radius,y:g.y,width:g.width+2*element.data.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),nodes=Array.from({length:40},(_,i)=>({id:'n'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'n'+i,text:'',radius:10}})),cache=createProjectedPaintIndex(),sync=records=>cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);sync(nodes);assert.equal(calls,40);sync(structuredClone(nodes));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,title:'only title'}}:n));assert.equal(calls,40);sync(nodes.map((n,i)=>i===0?{...n,data:{...n.data,radius:25}}:n));assert.equal(calls,41);sync(nodes.map((n,i)=>i===1?{...n,incarnation:2}:n));assert.equal(calls,43);});
|
||||||
|
|
||||||
|
// 1000节点反复同步不重复捕获依赖;单节点位置改动捕获1项,全局变换改动捕获全部。
|
||||||
test('unchanged paint rows retain their captured dependencies across metadata and ACK copies',()=>{
|
test('unchanged paint rows retain their captured dependencies across metadata and ACK copies',()=>{
|
||||||
const registry=createRegistry(builtinComponents()),records=Array.from({length:1000},(_,i)=>({id:'stable-'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'stable',text:''}})),document=createDocument('p',[{id:'p',parent:null}],records),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();projection.transforms.set('p',{...projection.transforms.get('p')});cache.sync(projection,registry);assert.equal(cache.stats().captured,1000);for(let i=0;i<10;i++)cache.sync({...projection},registry);assert.equal(cache.stats().captured,1000);cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);assert.equal(cache.stats().captured,1000);
|
const registry=createRegistry(builtinComponents()),records=Array.from({length:1000},(_,i)=>({id:'stable-'+i,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:i*200,y:0,width:100,height:100,rotation:0},data:{title:'stable',text:''}})),document=createDocument('p',[{id:'p',parent:null}],records),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();projection.transforms.set('p',{...projection.transforms.get('p')});cache.sync(projection,registry);assert.equal(cache.stats().captured,1000);for(let i=0;i<10;i++)cache.sync({...projection},registry);assert.equal(cache.stats().captured,1000);cache.sync(projectCanvas(createDocument('p',[{id:'p',parent:null}],records),'p',registry,new Set(),()=>undefined),registry);assert.equal(cache.stats().captured,1000);
|
||||||
projection.nodes[0].position.x+=500;cache.sync(projection,registry);assert.equal(cache.stats().captured,1001);projection.transforms.get('p').x+=100;cache.sync(projection,registry);assert.equal(cache.stats().captured,2001);
|
projection.nodes[0].position.x+=500;cache.sync(projection,registry);assert.equal(cache.stats().captured,1001);projection.transforms.get('p').x+=100;cache.sync(projection,registry);assert.equal(cache.stats().captured,2001);
|
||||||
});
|
});
|
||||||
|
// 分别测试未声明与声明paint依赖;原地改嵌套radius/旋转仍可发现,先前捕获快照不能别名到调用方对象。
|
||||||
test('mutable nested paint data and geometry are compared against detached dependency snapshots',()=>{
|
test('mutable nested paint data and geometry are compared against detached dependency snapshots',()=>{
|
||||||
for(const declared of [undefined,['paint']]){
|
for(const declared of [undefined,['paint']]){
|
||||||
const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:declared,bounds({element,geometry:g}){return{x:g.x-element.data.paint.radius,y:g.y,width:g.width+2*element.data.paint.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),record={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:'',paint:{radius:10}}},document=createDocument('p',[{id:'p',parent:null}],[record]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),mutable={...projection,document:{...document,elements:new Map([['a',record]])}},cache=createProjectedPaintIndex();assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),false);record.data.paint.radius=30;assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),true);assert.equal(cache.stats().captured,2);record.geometry.rotation=45;cache.sync(mutable,registry);assert.equal(cache.stats().captured,3);assert.equal(Object.isFrozen(record.data.paint),false);
|
const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,dataDependencies:declared,bounds({element,geometry:g}){return{x:g.x-element.data.paint.radius,y:g.y,width:g.width+2*element.data.paint.radius,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),record={id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:'',paint:{radius:10}}},document=createDocument('p',[{id:'p',parent:null}],[record]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),mutable={...projection,document:{...document,elements:new Map([['a',record]])}},cache=createProjectedPaintIndex();assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),false);record.data.paint.radius=30;assert.equal(cache.sync(mutable,registry).query({x:-25,y:0,width:1,height:1}).has('a'),true);assert.equal(cache.stats().captured,2);record.geometry.rotation=45;cache.sync(mutable,registry);assert.equal(cache.stats().captured,3);assert.equal(Object.isFrozen(record.data.paint),false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 插入renderer-unavailable标记后改用占位绘制规则,不继续用原组件外扩范围;移除标记恢复,范围缓存须失效。
|
||||||
test('renderer fallback switches paint protocols even when data and projected geometry stay equal',()=>{
|
test('renderer fallback switches paint protocols even when data and projected geometry stay equal',()=>{
|
||||||
const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,bounds({geometry:g}){return{x:g.x-100,y:g.y,width:g.width+200,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),document=createDocument('p',[{id:'p',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:''}}]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));projection.nodes[0].data.__headlessFallback={reason:'renderer-unavailable',componentId:'text',dataVersion:1,handles:[]};assert.equal(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).size,0);assert.equal(cache.stats().captured,2);delete projection.nodes[0].data.__headlessFallback;assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));
|
const text=builtinComponents().find(c=>c.id==='text'),geometry={...text.geometry,bounds({geometry:g}){return{x:g.x-100,y:g.y,width:g.width+200,height:g.height};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),document=createDocument('p',[{id:'p',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:''}}]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),cache=createProjectedPaintIndex();assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));projection.nodes[0].data.__headlessFallback={reason:'renderer-unavailable',componentId:'text',dataVersion:1,handles:[]};assert.equal(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).size,0);assert.equal(cache.stats().captured,2);delete projection.nodes[0].data.__headlessFallback;assert.ok(cache.sync(projection,registry).query({x:-50,y:0,width:1,height:1}).has('a'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* ReactFlow投影对象/数组复用的正确性测试。用引用断言区分无关变化、实际数据/选择/测量变化及owner/生命周期替换。
|
||||||
|
* 缓存必须保留最新document来源,失败准备不能部分替换已发布节点表。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
||||||
import {createProjectionCache} from '../../src/lib/canvas-bindings/projection-cache.ts';
|
import {createProjectionCache} from '../../src/lib/canvas-bindings/projection-cache.ts';
|
||||||
|
// root含A/B及edge;可扩展child/frame构造场景坐标相同但owner不同的情况。
|
||||||
const registry=createRegistry(builtinComponents()),node=(id,owner='root')=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:'source'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}),edge={id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{title:''}},document=createDocument('project',[{id:'root',parent:null}],[node('A'),node('B'),edge]);
|
const registry=createRegistry(builtinComponents()),node=(id,owner='root')=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:'source'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}),edge={id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{title:''}},document=createDocument('project',[{id:'root',parent:null}],[node('A'),node('B'),edge]);
|
||||||
|
/** 先完整投影,再交reuse;scope是视图缓存身份,selection与geometry回调用于制造UI及预览变化。 */
|
||||||
const project=(cache,scope,document,selection=new Set(),geometry=()=>undefined)=>cache.reuse(scope,document,'root',projectCanvas(document,'root',registry,selection,geometry));
|
const project=(cache,scope,document,selection=new Set(),geometry=()=>undefined)=>cache.reuse(scope,document,'root',projectCanvas(document,'root',registry,selection,geometry));
|
||||||
|
// 增加C或ACK等价克隆不替换A/B/edge;变换表可新建,不能据此强制所有元素重挂载。
|
||||||
test('unchanged projection objects survive unrelated additions and equivalent canonical record clones',()=>{
|
test('unchanged projection objects survive unrelated additions and equivalent canonical record clones',()=>{
|
||||||
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),added=applyChanges(document,[{kind:'element',id:'C',before:null,after:node('C')}]),second=project(cache,scope,added);assert.equal(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]);assert.equal(first.edges[0],second.edges[0]);assert.notEqual(first.transforms,second.transforms);
|
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),added=applyChanges(document,[{kind:'element',id:'C',before:null,after:node('C')}]),second=project(cache,scope,added);assert.equal(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]);assert.equal(first.edges[0],second.edges[0]);assert.notEqual(first.transforms,second.transforms);
|
||||||
const cloned=createDocument('project',[...added.canvases.values()],[...added.elements.values()]),third=project(cache,scope,cloned);assert.equal(third.nodes[0],second.nodes[0]);assert.equal(third.edges[0],second.edges[0]);
|
const cloned=createDocument('project',[...added.canvases.values()],[...added.elements.values()]),third=project(cache,scope,cloned);assert.equal(third.nodes[0],second.nodes[0]);assert.equal(third.edges[0],second.edges[0]);
|
||||||
});
|
});
|
||||||
|
// 逐步改A正文、选B、预览A、补测量和改edge标题,只刷新实际相关投影,测量值必须保留。
|
||||||
test('content, selection, preview geometry, measurements and edges refresh only affected projections',()=>{
|
test('content, selection, preview geometry, measurements and edges refresh only affected projections',()=>{
|
||||||
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'edited',beforePresent:true,afterPresent:true}]),second=project(cache,scope,changed);assert.notEqual(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]);
|
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'edited',beforePresent:true,afterPresent:true}]),second=project(cache,scope,changed);assert.notEqual(first.nodes[0],second.nodes[0]);assert.equal(first.nodes[1],second.nodes[1]);
|
||||||
const selected=project(cache,scope,changed,new Set(['B']));assert.equal(second.nodes[0],selected.nodes[0]);assert.notEqual(second.nodes[1],selected.nodes[1]);const preview=project(cache,scope,changed,new Set(['B']),id=>id==='A'?{...node('A').geometry,x:40}:undefined);assert.notEqual(preview.nodes[0],selected.nodes[0]);assert.equal(preview.nodes[1],selected.nodes[1]);
|
const selected=project(cache,scope,changed,new Set(['B']));assert.equal(second.nodes[0],selected.nodes[0]);assert.notEqual(second.nodes[1],selected.nodes[1]);const preview=project(cache,scope,changed,new Set(['B']),id=>id==='A'?{...node('A').geometry,x:40}:undefined);assert.notEqual(preview.nodes[0],selected.nodes[0]);assert.equal(preview.nodes[1],selected.nodes[1]);
|
||||||
const measured=projectCanvas(changed,'root',registry,new Set(['B']),()=>undefined);measured.nodes[0].measured={width:101,height:100};const result=cache.reuse(scope,changed,'root',measured);assert.equal(result.nodes[0].measured.width,101);
|
const measured=projectCanvas(changed,'root',registry,new Set(['B']),()=>undefined);measured.nodes[0].measured={width:101,height:100};const result=cache.reuse(scope,changed,'root',measured);assert.equal(result.nodes[0].measured.width,101);
|
||||||
const edgeChanged=applyChanges(changed,[{kind:'field',target:{id:'edge',incarnation:1},field:'data.title',before:'',after:'edge title',beforePresent:true,afterPresent:true}]),last=project(cache,scope,edgeChanged);assert.notEqual(last.edges[0],first.edges[0]);assert.equal(last.edges[0].data.title,'edge title');
|
const edgeChanged=applyChanges(changed,[{kind:'field',target:{id:'edge',incarnation:1},field:'data.title',before:'',after:'edge title',beforePresent:true,afterPresent:true}]),last=project(cache,scope,edgeChanged);assert.notEqual(last.edges[0],first.edges[0]);assert.equal(last.edges[0].data.title,'edge title');
|
||||||
});
|
});
|
||||||
|
// incarnation或scope变化不复用;移入child即使场景position相同也必须换对象,删除/clear后不能复活旧缓存。
|
||||||
test('lifecycle, ownership, view scope and deletion never reuse an obsolete projection',()=>{
|
test('lifecycle, ownership, view scope and deletion never reuse an obsolete projection',()=>{
|
||||||
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),restored=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.notEqual(project(cache,scope,restored).nodes[0],first.nodes[0]);const prior=project(cache,scope,document);assert.notEqual(project(cache,{},document).nodes[0],prior.nodes[0]);
|
const cache=createProjectionCache(),scope={},first=project(cache,scope,document),restored=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.notEqual(project(cache,scope,restored).nodes[0],first.nodes[0]);const prior=project(cache,scope,document);assert.notEqual(project(cache,{},document).nodes[0],prior.nodes[0]);
|
||||||
const canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame'},beforeMove=createDocument('project',canvases,[...document.elements.values(),frame]);const before=project(cache,scope,beforeMove).nodes.find(value=>value.id==='A');const moved=createDocument('project',canvases,[...beforeMove.elements.values()].map(record=>record.id==='A'?{...record,ownerCanvasId:'child',geometry:{...record.geometry,x:-24,y:-36}}:record)),after=project(cache,scope,moved).nodes.find(value=>value.id==='A');assert.deepEqual(before.position,after.position);assert.notEqual(before,after);
|
const canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame'},beforeMove=createDocument('project',canvases,[...document.elements.values(),frame]);const before=project(cache,scope,beforeMove).nodes.find(value=>value.id==='A');const moved=createDocument('project',canvases,[...beforeMove.elements.values()].map(record=>record.id==='A'?{...record,ownerCanvasId:'child',geometry:{...record.geometry,x:-24,y:-36}}:record)),after=project(cache,scope,moved).nodes.find(value=>value.id==='A');assert.deepEqual(before.position,after.position);assert.notEqual(before,after);
|
||||||
const current=project(cache,scope,document),empty=createDocument('project',[{id:'root',parent:null}]);project(cache,scope,empty);assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);cache.clear();assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);
|
const current=project(cache,scope,document),empty=createDocument('project',[{id:'root',parent:null}]);project(cache,scope,empty);assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);cache.clear();assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 平移不改变节点内部测量可保留,端口/子图scale或作用域变更不可沿用;clear后getNode为空。
|
||||||
test('measurement reuse preserves translated bounds but invalidates changed ports, scale and lifecycle',()=>{
|
test('measurement reuse preserves translated bounds but invalidates changed ports, scale and lifecycle',()=>{
|
||||||
const cache=createProjectionCache(),scope={},canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[]}},original=createDocument('project',canvases,[frame,node('A','child')]);project(cache,scope,original);assert.equal(cache.getNode('F').data.title,'F');
|
const cache=createProjectionCache(),scope={},canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[]}},original=createDocument('project',canvases,[frame,node('A','child')]);project(cache,scope,original);assert.equal(cache.getNode('F').data.title,'F');
|
||||||
const translated=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'geometry',before:frame.geometry,after:{...frame.geometry,x:200}}]),next=projectCanvas(translated,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,translated,'root',next.nodes.find(value=>value.id==='F')),true);
|
const translated=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'geometry',before:frame.geometry,after:{...frame.geometry,x:200}}]),next=projectCanvas(translated,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,translated,'root',next.nodes.find(value=>value.id==='F')),true);
|
||||||
const ports=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.ports',before:[],after:[{id:'input',direction:'input',childNodeId:'A'}],beforePresent:true,afterPresent:true}]),portProjection=projectCanvas(ports,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,ports,'root',portProjection.nodes.find(value=>value.id==='F')),false);
|
const ports=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.ports',before:[],after:[{id:'input',direction:'input',childNodeId:'A'}],beforePresent:true,afterPresent:true}]),portProjection=projectCanvas(ports,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,ports,'root',portProjection.nodes.find(value=>value.id==='F')),false);
|
||||||
const scaled=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.presentation',before:null,after:{contentScale:2},beforePresent:false,afterPresent:true}]),scaleProjection=projectCanvas(scaled,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,scaled,'root',scaleProjection.nodes.find(value=>value.id==='A')),false);assert.equal(cache.canRetainMeasurement({},original,'root',projectCanvas(original,'root',registry,new Set(),()=>undefined).nodes[0]),false);cache.clear();assert.equal(cache.getNode('A'),undefined);
|
const scaled=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.presentation',before:null,after:{contentScale:2},beforePresent:false,afterPresent:true}]),scaleProjection=projectCanvas(scaled,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,scaled,'root',scaleProjection.nodes.find(value=>value.id==='A')),false);assert.equal(cache.canRetainMeasurement({},original,'root',projectCanvas(original,'root',registry,new Set(),()=>undefined).nodes[0]),false);cache.clear();assert.equal(cache.getNode('A'),undefined);
|
||||||
});
|
});
|
||||||
|
// 深相等新文档可共享nodes/edges数组,但result.document必须指向新来源;仅A正文变更不换edges/B。
|
||||||
test('unchanged projection arrays retain identity while fresh document/query provenance remains current',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),clone=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>structuredClone(record))),second=project(cache,scope,clone);assert.strictEqual(second.nodes,first.nodes);assert.strictEqual(second.edges,first.edges);assert.strictEqual(second.document,clone);assert.notStrictEqual(second.document,first.document);const changed=applyChanges(clone,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'array edit',beforePresent:true,afterPresent:true}]),third=project(cache,scope,changed);assert.notStrictEqual(third.nodes,second.nodes);assert.strictEqual(third.edges,second.edges);assert.strictEqual(third.nodes[1],second.nodes[1]);});
|
test('unchanged projection arrays retain identity while fresh document/query provenance remains current',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),clone=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(record=>structuredClone(record))),second=project(cache,scope,clone);assert.strictEqual(second.nodes,first.nodes);assert.strictEqual(second.edges,first.edges);assert.strictEqual(second.document,clone);assert.notStrictEqual(second.document,first.document);const changed=applyChanges(clone,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'array edit',beforePresent:true,afterPresent:true}]),third=project(cache,scope,changed);assert.notStrictEqual(third.nodes,second.nodes);assert.strictEqual(third.edges,second.edges);assert.strictEqual(third.nodes[1],second.nodes[1]);});
|
||||||
|
// 节点顺序反转必须换数组并按新顺序返回;scope替换与clear也不共享旧数组身份。
|
||||||
test('array reuse respects order, scope replacement and explicit clearing',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes.reverse();const reverse=cache.reuse(scope,document,'root',raw);assert.notStrictEqual(reverse.nodes,first.nodes);assert.deepEqual(reverse.nodes.map(node=>node.id),first.nodes.map(node=>node.id).reverse());const replacement=project(cache,{},document);assert.notStrictEqual(replacement.nodes,first.nodes);cache.clear();assert.notStrictEqual(project(cache,scope,document).nodes,first.nodes);});
|
test('array reuse respects order, scope replacement and explicit clearing',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes.reverse();const reverse=cache.reuse(scope,document,'root',raw);assert.notStrictEqual(reverse.nodes,first.nodes);assert.deepEqual(reverse.nodes.map(node=>node.id),first.nodes.map(node=>node.id).reverse());const replacement=project(cache,{},document);assert.notStrictEqual(replacement.nodes,first.nodes);cache.clear();assert.notStrictEqual(project(cache,scope,document).nodes,first.nodes);});
|
||||||
|
// 新投影边ID不在文档时整个reuse失败,缓存A仍是旧对象;随后合法输入再采用新正文。
|
||||||
test('an invalid edge cannot partially replace the retained node table',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'prepared only',beforePresent:true,afterPresent:true}]),raw=projectCanvas(changed,'root',registry,new Set(),()=>undefined);raw.edges.push({...raw.edges[0],id:'missing-record'});assert.throws(()=>cache.reuse(scope,changed,'root',raw),/projection_identity_missing/);assert.strictEqual(cache.getNode('A'),first.nodes.find(node=>node.id==='A'));assert.equal(project(cache,scope,changed).nodes.find(node=>node.id==='A').data.text,'prepared only');});
|
test('an invalid edge cannot partially replace the retained node table',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'source',after:'prepared only',beforePresent:true,afterPresent:true}]),raw=projectCanvas(changed,'root',registry,new Set(),()=>undefined);raw.edges.push({...raw.edges[0],id:'missing-record'});assert.throws(()=>cache.reuse(scope,changed,'root',raw),/projection_identity_missing/);assert.strictEqual(cache.getNode('A'),first.nodes.find(node=>node.id==='A'));assert.equal(project(cache,scope,changed).nodes.find(node=>node.id==='A').data.text,'prepared only');});
|
||||||
|
// 新节点包络只多measured时保留相等嵌套props引用,换数组以暴露测量变化,冻结原输入不应被修改。
|
||||||
test('measurement-only updates retain equal data/style/position props without mutating frozen source values',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes=raw.nodes.map(node=>Object.freeze({...node,measured:{width:node.width,height:node.height}}));const result=cache.reuse(scope,document,'root',raw);for(let i=0;i<result.nodes.length;i++){assert.strictEqual(result.nodes[i].data,first.nodes[i].data);assert.strictEqual(result.nodes[i].style,first.nodes[i].style);assert.strictEqual(result.nodes[i].position,first.nodes[i].position);assert.ok(result.nodes[i].measured);}assert.notStrictEqual(result.nodes,first.nodes);});
|
test('measurement-only updates retain equal data/style/position props without mutating frozen source values',()=>{const cache=createProjectionCache(),scope={},first=project(cache,scope,document),raw=projectCanvas(document,'root',registry,new Set(),()=>undefined);raw.nodes=raw.nodes.map(node=>Object.freeze({...node,measured:{width:node.width,height:node.height}}));const result=cache.reuse(scope,document,'root',raw);for(let i=0;i<result.nodes.length;i++){assert.strictEqual(result.nodes[i].data,first.nodes[i].data);assert.strictEqual(result.nodes[i].style,first.nodes[i].style);assert.strictEqual(result.nodes[i].position,first.nodes[i].position);assert.ok(result.nodes[i].measured);}assert.notStrictEqual(result.nodes,first.nodes);});
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 文档查询索引的语义与增量更新回归。用父子canvas、跨层连线及声明引用构造关系图,比较增量维护与完整重建结果。
|
||||||
|
* 空间测试使用确定性几何与计数,不依赖浏览器;complete/opaque覆盖信息必须随同文档发布,不能凭“未查到”推断对象不存在。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {removalChanges} from '../../src/lib/canvas-core/structure.ts';
|
import {removalChanges} from '../../src/lib/canvas-core/structure.ts';
|
||||||
|
// 节点/边工厂允许指定owner及几何;跨层in边归父canvas,local边归子canvas。
|
||||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text',geometry={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:10,y:20,width:100,height:60,rotation:0,...geometry}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,structure:'connection',ownerCanvasId:owner,componentId:'connection',dataVersion:1,source:{elementId:source},target:{elementId:target},data:{},path:null});
|
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text',geometry={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:10,y:20,width:100,height:60,rotation:0,...geometry}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,structure:'connection',ownerCanvasId:owner,componentId:'connection',dataVersion:1,source:{elementId:source},target:{elementId:target},data:{},path:null});
|
||||||
|
// P中F作为S入口,F的ports引用A;用来同时覆盖owner、端点与数组反向引用三类索引。
|
||||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',ports:[{id:'p',nodeId:'A'}]}},node('A','S'),node('B','S'),edge('in','C','A'),edge('local','A','B','S')]);
|
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',ports:[{id:'p',nodeId:'A'}]}},node('A','S'),node('B','S'),edge('in','C','A'),edge('local','A','B','S')]);
|
||||||
|
/** Map<id,Set>规范为排序数组,只消除集合遍历次序差异,不改变实际成员。 */
|
||||||
const sets=map=>[...map].map(([id,ids])=>[id,[...ids].sort()]).sort(([a],[b])=>a.localeCompare(b));
|
const sets=map=>[...map].map(([id,ids])=>[id,[...ids].sort()]).sort(([a],[b])=>a.localeCompare(b));
|
||||||
|
/** 全量比较关系表、入口映射、opaque集合、每canvas成员及空间entries/cells/oversized;不只比查询条数。 */
|
||||||
function parity(actual,expected){for(const key of ['ownerMembers','canvasChildren','incidentEdges','elementReferrers','canvasReferrers'])assert.deepEqual(sets(actual.relations[key]),sets(expected.relations[key]),key);assert.deepEqual([...actual.relations.childCanvasByEntry].sort(),[...expected.relations.childCanvasByEntry].sort());assert.deepEqual([...actual.relations.opaqueReferences].sort(),[...expected.relations.opaqueReferences].sort());for(const id of actual.document.canvases.keys()){assert.deepEqual(actual.members(id),expected.members(id));assert.deepEqual(actual.childCanvases(id),expected.childCanvases(id));assert.deepEqual([...actual.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)),[...expected.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)));assert.deepEqual(sets(actual.spatial.get(id)?.cells??new Map()),sets(expected.spatial.get(id)?.cells??new Map()));assert.deepEqual([...(actual.spatial.get(id)?.oversized??[])].sort(),[...(expected.spatial.get(id)?.oversized??[])].sort());}}
|
function parity(actual,expected){for(const key of ['ownerMembers','canvasChildren','incidentEdges','elementReferrers','canvasReferrers'])assert.deepEqual(sets(actual.relations[key]),sets(expected.relations[key]),key);assert.deepEqual([...actual.relations.childCanvasByEntry].sort(),[...expected.relations.childCanvasByEntry].sort());assert.deepEqual([...actual.relations.opaqueReferences].sort(),[...expected.relations.opaqueReferences].sort());for(const id of actual.document.canvases.keys()){assert.deepEqual(actual.members(id),expected.members(id));assert.deepEqual(actual.childCanvases(id),expected.childCanvases(id));assert.deepEqual([...actual.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)),[...expected.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)));assert.deepEqual(sets(actual.spatial.get(id)?.cells??new Map()),sets(expected.spatial.get(id)?.cells??new Map()));assert.deepEqual([...(actual.spatial.get(id)?.oversized??[])].sort(),[...(expected.spatial.get(id)?.oversized??[])].sort());}}
|
||||||
|
|
||||||
|
// 检查父子成员、入边和port引用,集合无写方法;未知组件标记引用不完整,缺canvas与更换registry明确拒绝。
|
||||||
test('query snapshots index ownership, incoming edges and declared array references without mutable maps',()=>{
|
test('query snapshots index ownership, incoming edges and declared array references without mutable maps',()=>{
|
||||||
const q=createDocumentQueries(document,registry);assert.deepEqual(q.members('P'),['C','F','in']);assert.deepEqual(q.members('S'),['A','B','local']);assert.deepEqual(q.incident('A'),['in','local']);assert.equal(q.relations.childCanvasByEntry.get('F'),'S');assert.deepEqual([...q.relations.elementReferrers.get('A')],['F']);assert.equal(q.relations.referenceComplete,true);assert.equal(q.relations.ownerMembers.set,undefined);assert.equal(q.relations.ownerMembers.get('S').add,undefined);assert.equal(q.spatial.get('S').cells.set,undefined);assert.ok(Object.isFrozen(q.spatial.get('S').entries.get('A')));assert.deepEqual(removalChanges(document,{id:'F',incarnation:1},q),removalChanges(document,{id:'F',incarnation:1}));
|
const q=createDocumentQueries(document,registry);assert.deepEqual(q.members('P'),['C','F','in']);assert.deepEqual(q.members('S'),['A','B','local']);assert.deepEqual(q.incident('A'),['in','local']);assert.equal(q.relations.childCanvasByEntry.get('F'),'S');assert.deepEqual([...q.relations.elementReferrers.get('A')],['F']);assert.equal(q.relations.referenceComplete,true);assert.equal(q.relations.ownerMembers.set,undefined);assert.equal(q.relations.ownerMembers.get('S').add,undefined);assert.equal(q.spatial.get('S').cells.set,undefined);assert.ok(Object.isFrozen(q.spatial.get('S').entries.get('A')));assert.deepEqual(removalChanges(document,{id:'F',incarnation:1},q),removalChanges(document,{id:'F',incarnation:1}));
|
||||||
const opaque=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('unknown','P','future')]),partial=createDocumentQueries(opaque,registry);assert.equal(partial.relations.referenceComplete,false);assert.deepEqual([...partial.relations.opaqueReferences],['unknown']);assert.throws(()=>q.members('missing'),/canvas_not_loaded/);assert.throws(()=>advanceDocumentQueries(q,document,createRegistry(builtinComponents()),0),/query_registry_changed/);
|
const opaque=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('unknown','P','future')]),partial=createDocumentQueries(opaque,registry);assert.equal(partial.relations.referenceComplete,false);assert.deepEqual([...partial.relations.opaqueReferences],['unknown']);assert.throws(()=>q.members('missing'),/canvas_not_loaded/);assert.throws(()=>advanceDocumentQueries(q,document,createRegistry(builtinComponents()),0),/query_registry_changed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 标题/运行元数据变化复用空间表;runtime失败时根和索引一起不发布,合法几何只更新对应owner表,undo再与重建比较。
|
||||||
test('title and metadata commits reuse spatial tables; listeners observe matching document/index revisions and rejected commits keep both',()=>{
|
test('title and metadata commits reuse spatial tables; listeners observe matching document/index revisions and rejected commits keep both',()=>{
|
||||||
let fail=false,next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:{tag:0},reduceRuntime:previous=>{if(fail)throw Error('runtime failure');return previous;}}),initial=editor.getQueries(),members=initial.members('S');editor.subscribe((_,snapshot)=>{const q=editor.getQueries();assert.equal(q.document,snapshot.document);assert.equal(q.revision,snapshot.revision);});
|
let fail=false,next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:{tag:0},reduceRuntime:previous=>{if(fail)throw Error('runtime failure');return previous;}}),initial=editor.getQueries(),members=initial.members('S');editor.subscribe((_,snapshot)=>{const q=editor.getQueries();assert.equal(q.document,snapshot.document);assert.equal(q.revision,snapshot.revision);});
|
||||||
assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'renamed'}}).status,'applied');const titled=editor.getQueries();assert.equal(titled.spatial,initial.spatial);assert.equal(titled.relations.ownerMembers,initial.relations.ownerMembers);assert.equal(titled.members('S'),members);assert.equal(editor.getQueries(initial.document),initial);assert.equal(editor.updateRuntime(()=>({tag:1})).status,'applied');assert.equal(editor.getQueries(),titled);fail=true;const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'rejected');assert.equal(editor.getSnapshot(),before);assert.equal(editor.getQueries(),titled);fail=false;
|
assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{title:'renamed'}}).status,'applied');const titled=editor.getQueries();assert.equal(titled.spatial,initial.spatial);assert.equal(titled.relations.ownerMembers,initial.relations.ownerMembers);assert.equal(titled.members('S'),members);assert.equal(editor.getQueries(initial.document),initial);assert.equal(editor.updateRuntime(()=>({tag:1})).status,'applied');assert.equal(editor.getQueries(),titled);fail=true;const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'rejected');assert.equal(editor.getSnapshot(),before);assert.equal(editor.getQueries(),titled);fail=false;
|
||||||
assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'applied');assert.equal(editor.getQueries().spatial.get('P'),initial.spatial.get('P'));assert.notEqual(editor.getQueries().spatial.get('S'),initial.spatial.get('S'));assert.deepEqual(initial.candidateNodes('S',{x:0,y:0,width:150,height:150}),['A','B']);assert.deepEqual(editor.getQueries().candidateNodes('S',{x:0,y:0,width:150,height:150}),['B']);assert.equal(editor.history.undo().status,'applied');parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
assert.equal(editor.dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:700}}).status,'applied');assert.equal(editor.getQueries().spatial.get('P'),initial.spatial.get('P'));assert.notEqual(editor.getQueries().spatial.get('S'),initial.spatial.get('S'));assert.deepEqual(initial.candidateNodes('S',{x:0,y:0,width:150,height:150}),['A','B']);assert.deepEqual(editor.getQueries().candidateNodes('S',{x:0,y:0,width:150,height:150}),['B']);assert.equal(editor.history.undo().status,'applied');parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 40轮几何/port编辑穿插跨owner移动,每步重建对照;删除恢复、ACK顺序重排也保持等价,同内容再协调复用查询对象。
|
||||||
test('incremental queries match rebuilt indexes through transactions, cross-owner repair, deletion/restore and canonical reordering',()=>{
|
test('incremental queries match rebuilt indexes through transactions, cross-owner repair, deletion/restore and canonical reordering',()=>{
|
||||||
let next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:0}),check=()=>parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
let next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:0}),check=()=>parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
||||||
for(let i=0;i<40;i++){const target=editor.getSnapshot().document.elements.get(i%2?'A':'B');assert.equal(editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:i*27,rotation:(i*11)%360}});tx.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{ports:[{id:'p',nodeId:target.id}]}});}).status,'applied');check();if(i%5===0){assert.equal(editor.dispatch({type:'element.move',target,canvasId:target.ownerCanvasId==='S'?'P':'S'}).status,'applied');check();}}
|
for(let i=0;i<40;i++){const target=editor.getSnapshot().document.elements.get(i%2?'A':'B');assert.equal(editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:i*27,rotation:(i*11)%360}});tx.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{ports:[{id:'p',nodeId:target.id}]}});}).status,'applied');check();if(i%5===0){assert.equal(editor.dispatch({type:'element.move',target,canvasId:target.ownerCanvasId==='S'?'P':'S'}).status,'applied');check();}}
|
||||||
@@ -25,21 +36,25 @@ test('incremental queries match rebuilt indexes through transactions, cross-owne
|
|||||||
const root=editor.getSnapshot(),canonical=createDocument(root.document.projectId,[...root.document.canvases.values()].reverse(),[...root.document.elements.values()].reverse(),root.document.tombstones);assert.equal(editor.reconcile(canonical,1,root.rootRevision).status,'applied');check();const after=editor.getQueries(),same=createDocument(canonical.projectId,[...canonical.canvases.values()],[...canonical.elements.values()],canonical.tombstones);assert.equal(editor.reconcile(same,2,editor.getSnapshot().rootRevision).status,'applied');assert.equal(editor.getQueries(),after);
|
const root=editor.getSnapshot(),canonical=createDocument(root.document.projectId,[...root.document.canvases.values()].reverse(),[...root.document.elements.values()].reverse(),root.document.tombstones);assert.equal(editor.reconcile(canonical,1,root.rootRevision).status,'applied');check();const after=editor.getQueries(),same=createDocument(canonical.projectId,[...canonical.canvases.values()],[...canonical.elements.values()],canonical.tombstones);assert.equal(editor.reconcile(same,2,editor.getSnapshot().rootRevision).status,'applied');assert.equal(editor.getQueries(),after);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 巨大或溢出几何走oversized保守路径;旋转命中检查内外点与角点,非法查询范围拒绝,不把不完整结果说成完整。
|
||||||
test('owner-local grid conservatively selects rotated/huge nodes and precisely hits corners with finite query budgets',()=>{
|
test('owner-local grid conservatively selects rotated/huge nodes and precisely hits corners with finite query budgets',()=>{
|
||||||
const source=createDocument('project',[{id:'P',parent:null}],[node('rotated','P','text',{x:0,y:0,width:100,height:10,rotation:45}),node('huge','P','text',{x:-1000000,y:-1000000,width:2000000,height:2000000}),node('overflow','P','text',{x:Number.MAX_VALUE,y:0,width:Number.MAX_VALUE,height:100})]),q=createDocumentQueries(source,registry);assert.ok(q.spatial.get('P').oversized.has('huge'));assert.ok(q.spatial.get('P').oversized.has('overflow'));assert.deepEqual(q.hitNodeBounds('P',{x:50,y:5}).ids,['rotated','huge']);assert.equal(q.hitNodeBounds('P',{x:50,y:5}).complete,false);assert.ok(!q.hitNodeBounds('P',{x:10,y:40}).ids.includes('rotated'));assert.ok(q.candidateNodes('P',{x:-1e12,y:-1e12,width:2e12,height:2e12}).includes('rotated'));assert.throws(()=>q.candidateNodes('P',{x:0,y:0,width:-1,height:10}),/invalid_query_bounds/);
|
const source=createDocument('project',[{id:'P',parent:null}],[node('rotated','P','text',{x:0,y:0,width:100,height:10,rotation:45}),node('huge','P','text',{x:-1000000,y:-1000000,width:2000000,height:2000000}),node('overflow','P','text',{x:Number.MAX_VALUE,y:0,width:Number.MAX_VALUE,height:100})]),q=createDocumentQueries(source,registry);assert.ok(q.spatial.get('P').oversized.has('huge'));assert.ok(q.spatial.get('P').oversized.has('overflow'));assert.deepEqual(q.hitNodeBounds('P',{x:50,y:5}).ids,['rotated','huge']);assert.equal(q.hitNodeBounds('P',{x:50,y:5}).complete,false);assert.ok(!q.hitNodeBounds('P',{x:10,y:40}).ids.includes('rotated'));assert.ok(q.candidateNodes('P',{x:-1e12,y:-1e12,width:2e12,height:2e12}).includes('rotated'));assert.throws(()=>q.candidateNodes('P',{x:0,y:0,width:-1,height:10}),/invalid_query_bounds/);
|
||||||
const g=source.elements.get('rotated').geometry,angle=Math.PI/4,x=g.x+g.width/2+(g.width/2*Math.cos(angle)-g.height/2*Math.sin(angle)),y=g.y+g.height/2+(g.width/2*Math.sin(angle)+g.height/2*Math.cos(angle));assert.ok(q.hitNodeBounds('P',{x,y}).ids.includes('rotated'));
|
const g=source.elements.get('rotated').geometry,angle=Math.PI/4,x=g.x+g.width/2+(g.width/2*Math.cos(angle)-g.height/2*Math.sin(angle)),y=g.y+g.height/2+(g.width/2*Math.sin(angle)+g.height/2*Math.cos(angle));assert.ok(q.hitNodeBounds('P',{x,y}).ids.includes('rotated'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 生成100个不同角度/位置矩形,用正向旋转生成内外点;外点默认不命中,6单位容差可覆盖5单位边外位置。
|
||||||
test('rotated rectangles are found across grid cells with forward-generated inside/outside points',()=>{
|
test('rotated rectangles are found across grid cells with forward-generated inside/outside points',()=>{
|
||||||
const nodes=Array.from({length:100},(_,i)=>node(`n-${i}`,'P','text',{x:i*1000,y:(i%5)*700,width:50+i,height:20+i%9,rotation:(i*37)%360})),doc=createDocument('project',[{id:'P',parent:null}],nodes),q=createDocumentQueries(doc,registry);
|
const nodes=Array.from({length:100},(_,i)=>node(`n-${i}`,'P','text',{x:i*1000,y:(i%5)*700,width:50+i,height:20+i%9,rotation:(i*37)%360})),doc=createDocument('project',[{id:'P',parent:null}],nodes),q=createDocumentQueries(doc,registry);
|
||||||
for(const record of nodes){const g=record.geometry,angle=g.rotation*Math.PI/180,world=(x,y)=>({x:g.x+g.width/2+x*Math.cos(angle)-y*Math.sin(angle),y:g.y+g.height/2+x*Math.sin(angle)+y*Math.cos(angle)}),inside=world(g.width*.4,g.height*.4),outside=world(g.width/2+5,0);assert.ok(q.hitNodeBounds('P',inside).ids.includes(record.id));assert.ok(!q.hitNodeBounds('P',outside).ids.includes(record.id));assert.ok(q.hitNodeBounds('P',outside,6).ids.includes(record.id));}
|
for(const record of nodes){const g=record.geometry,angle=g.rotation*Math.PI/180,world=(x,y)=>({x:g.x+g.width/2+x*Math.cos(angle)-y*Math.sin(angle),y:g.y+g.height/2+x*Math.sin(angle)+y*Math.cos(angle)}),inside=world(g.width*.4,g.height*.4),outside=world(g.width/2+5,0);assert.ok(q.hitNodeBounds('P',inside).ids.includes(record.id));assert.ok(!q.hitNodeBounds('P',outside).ids.includes(record.id));assert.ok(q.hitNodeBounds('P',outside,6).ids.includes(record.id));}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 自定义数组引用去重;改canvas引用移除旧反向关系且复用无关表,声明值变非法后清该主体引用并标opaque。
|
||||||
test('reverse element/canvas references remove obsolete subjects and mark malformed or unsupported declarations incomplete',()=>{
|
test('reverse element/canvas references remove obsolete subjects and mark malformed or unsupported declarations incomplete',()=>{
|
||||||
const custom=createRegistry([...builtinComponents(),{id:'refs',dataVersion:1,structure:'node',validate:()=>true,features:[],copy:{references:[{path:['targets'],many:true,target:'element',external:'preserve'},{path:['scopes'],many:true,target:'canvas',external:'preserve'}],omit:[]}}]),r={...node('R','P','refs'),data:{targets:['A','A'],scopes:['S']}},r2={...node('R2','P','refs'),data:{targets:['B'],scopes:['S']}},doc=createDocument('project',[...document.canvases.values()],[...document.elements.values(),r,r2]),q=createDocumentQueries(doc,custom);
|
const custom=createRegistry([...builtinComponents(),{id:'refs',dataVersion:1,structure:'node',validate:()=>true,features:[],copy:{references:[{path:['targets'],many:true,target:'element',external:'preserve'},{path:['scopes'],many:true,target:'canvas',external:'preserve'}],omit:[]}}]),r={...node('R','P','refs'),data:{targets:['A','A'],scopes:['S']}},r2={...node('R2','P','refs'),data:{targets:['B'],scopes:['S']}},doc=createDocument('project',[...document.canvases.values()],[...document.elements.values(),r,r2]),q=createDocumentQueries(doc,custom);
|
||||||
assert.deepEqual([...q.relations.canvasReferrers.get('S')],['R','R2']);assert.deepEqual([...q.relations.elementReferrers.get('A')],['F','R']);const change={kind:'field',target:{id:'R',incarnation:1},field:'data.scopes',before:['S'],after:['P']},next=applyChanges(doc,[change]),updated=advanceDocumentQueries(q,next,custom,1,[change]);assert.deepEqual([...updated.relations.canvasReferrers.get('S')],['R2']);assert.deepEqual([...updated.relations.canvasReferrers.get('P')],['R']);assert.equal(updated.relations.elementReferrers,q.relations.elementReferrers);const invalid={kind:'field',target:{id:'R',incarnation:1},field:'data.targets',before:['A','A'],after:'invalid'},bad=advanceDocumentQueries(updated,applyChanges(next,[invalid]),custom,2,[invalid]);assert.equal(bad.relations.referenceComplete,false);assert.deepEqual([...bad.relations.opaqueReferences],['R']);assert.equal(bad.relations.canvasReferrers.has('P'),false);assert.deepEqual([...bad.relations.elementReferrers.get('A')],['F']);
|
assert.deepEqual([...q.relations.canvasReferrers.get('S')],['R','R2']);assert.deepEqual([...q.relations.elementReferrers.get('A')],['F','R']);const change={kind:'field',target:{id:'R',incarnation:1},field:'data.scopes',before:['S'],after:['P']},next=applyChanges(doc,[change]),updated=advanceDocumentQueries(q,next,custom,1,[change]);assert.deepEqual([...updated.relations.canvasReferrers.get('S')],['R2']);assert.deepEqual([...updated.relations.canvasReferrers.get('P')],['R']);assert.equal(updated.relations.elementReferrers,q.relations.elementReferrers);const invalid={kind:'field',target:{id:'R',incarnation:1},field:'data.targets',before:['A','A'],after:'invalid'},bad=advanceDocumentQueries(updated,applyChanges(next,[invalid]),custom,2,[invalid]);assert.equal(bad.relations.referenceComplete,false);assert.deepEqual([...bad.relations.opaqueReferences],['R']);assert.equal(bad.relations.canvasReferrers.has('P'),false);assert.deepEqual([...bad.relations.elementReferrers.get('A')],['F']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// runtime切完整覆盖时unknown由unloaded变absent,旧查询不变;foreign完整canvas声明被拒绝且根/查询不发布。
|
||||||
test('coverage changes share the metadata publication boundary and invalid coverage cannot partially publish a root',()=>{
|
test('coverage changes share the metadata publication boundary and invalid coverage cannot partially publish a root',()=>{
|
||||||
const editor=createEditor({registry,document,runtime:{complete:false},queryCoverage:snapshot=>({projectComplete:snapshot.runtime.complete,topologyComplete:true,completeCanvasIds:snapshot.runtime.complete?['P','S']:[]})});
|
const editor=createEditor({registry,document,runtime:{complete:false},queryCoverage:snapshot=>({projectComplete:snapshot.runtime.complete,topologyComplete:true,completeCanvasIds:snapshot.runtime.complete?['P','S']:[]})});
|
||||||
const before=editor.getSnapshot(),queries=editor.getQueries();assert.equal(queries.lookupElement('unknown').status,'unloaded');assert.equal(queries.membersResult('P').complete,false);let observed;
|
const before=editor.getSnapshot(),queries=editor.getQueries();assert.equal(queries.lookupElement('unknown').status,'unloaded');assert.equal(queries.membersResult('P').complete,false);let observed;
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 查询快照代次的GC保留回归。独立Node进程开启--expose-gc,用WeakRef区分显式持有的旧前缀与已丢弃代次。
|
||||||
|
* 这是受控强制GC场景,不能据此宣称生产内存没有任何泄漏或保证垃圾回收发生时机。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {spawnSync} from 'node:child_process';
|
import {spawnSync} from 'node:child_process';
|
||||||
|
// 同时测手动增量链、显式持有链和editor链,不只检查WeakRef消失,还检查旧前缀与最新查询都仍正确。
|
||||||
test('latest query methods do not keep discarded query/document generations alive; explicitly held prefixes still work',()=>{
|
test('latest query methods do not keep discarded query/document generations alive; explicitly held prefixes still work',()=>{
|
||||||
|
// 转file URL供子进程ESM动态脚本导入;子进程使用当前process.execPath,避免与主测试互相共享GC状态。
|
||||||
const core=new URL('../../src/lib/canvas-core/index.ts',import.meta.url).href,queries=new URL('../../src/lib/canvas-core/queries.ts',import.meta.url).href;
|
const core=new URL('../../src/lib/canvas-core/index.ts',import.meta.url).href,queries=new URL('../../src/lib/canvas-core/queries.ts',import.meta.url).href;
|
||||||
|
// 嵌入脚本的chain(keep)连续推进30代query,held只在keep=true保留初始查询;WeakRef本身不作为强引用。
|
||||||
|
// editorChain用真实editor推进同样30次几何,检查当前查询方法是否闭包保留旧document/query。
|
||||||
|
// 12次setImmediate+gc跨过任务边界后输出布尔存活状态,同时读取显式旧前缀及最新文档的x证明可用性。
|
||||||
const script=`
|
const script=`
|
||||||
import {createDocument,createEditor,applyChanges,createRegistry,builtinComponents} from ${JSON.stringify(core)};
|
import {createDocument,createEditor,applyChanges,createRegistry,builtinComponents} from ${JSON.stringify(core)};
|
||||||
import {createDocumentQueries,advanceDocumentQueries} from ${JSON.stringify(queries)};
|
import {createDocumentQueries,advanceDocumentQueries} from ${JSON.stringify(queries)};
|
||||||
@@ -12,5 +21,6 @@ test('latest query methods do not keep discarded query/document generations aliv
|
|||||||
function editorChain(){const editor=createEditor({registry,document:createDocument('p',[{id:'p',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:''}}]),queryCoverage:()=>({projectComplete:true,topologyComplete:true,completeCanvasIds:['p']})}),weakDocument=new WeakRef(editor.getSnapshot().document),weakQuery=new WeakRef(editor.getQueries());for(let i=1;i<=30;i++)editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{x:i}});return{editor,weakDocument,weakQuery};}
|
function editorChain(){const editor=createEditor({registry,document:createDocument('p',[{id:'p',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:''}}]),queryCoverage:()=>({projectComplete:true,topologyComplete:true,completeCanvasIds:['p']})}),weakDocument=new WeakRef(editor.getSnapshot().document),weakQuery=new WeakRef(editor.getQueries());for(let i=1;i<=30;i++)editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{x:i}});return{editor,weakDocument,weakQuery};}
|
||||||
const dropped=chain(false),kept=chain(true),owned=editorChain();for(let i=0;i<12;i++){await setImmediate();global.gc();}
|
const dropped=chain(false),kept=chain(true),owned=editorChain();for(let i=0;i<12;i++){await setImmediate();global.gc();}
|
||||||
console.log(JSON.stringify({droppedDocument:!!dropped.weakDocument.deref(),droppedQuery:!!dropped.weakQuery.deref(),keptDocument:!!kept.weakDocument.deref(),keptQuery:!!kept.weakQuery.deref(),oldX:kept.held.document.elements.get('a').geometry.x,newX:dropped.last.document.elements.get('a').geometry.x,members:dropped.last.members('p'),editorDocument:!!owned.weakDocument.deref(),editorQuery:!!owned.weakQuery.deref(),editorX:owned.editor.getSnapshot().document.elements.get('a').geometry.x}));`;
|
console.log(JSON.stringify({droppedDocument:!!dropped.weakDocument.deref(),droppedQuery:!!dropped.weakQuery.deref(),keptDocument:!!kept.weakDocument.deref(),keptQuery:!!kept.weakQuery.deref(),oldX:kept.held.document.elements.get('a').geometry.x,newX:dropped.last.document.elements.get('a').geometry.x,members:dropped.last.members('p'),editorDocument:!!owned.weakDocument.deref(),editorQuery:!!owned.weakQuery.deref(),editorX:owned.editor.getSnapshot().document.elements.get('a').geometry.x}));`;
|
||||||
|
// 子进程限时15秒,先要求正常退出,再比JSON;旧丢弃代次应回收,显式held应存活且oldX仍0,最新x=30。
|
||||||
const child=spawnSync(process.execPath,['--expose-gc','--input-type=module','-e',script],{encoding:'utf8',timeout:15000});assert.equal(child.status,0,child.stderr||String(child.error));assert.deepEqual(JSON.parse(child.stdout),{droppedDocument:false,droppedQuery:false,keptDocument:true,keptQuery:true,oldX:0,newX:30,members:['a'],editorDocument:false,editorQuery:false,editorX:30});
|
const child=spawnSync(process.execPath,['--expose-gc','--input-type=module','-e',script],{encoding:'utf8',timeout:15000});assert.equal(child.status,0,child.stderr||String(child.error));assert.deepEqual(JSON.parse(child.stdout),{droppedDocument:false,droppedQuery:false,keptDocument:true,keptQuery:true,oldX:0,newX:30,members:['a'],editorDocument:false,editorQuery:false,editorX:30});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* ACK 基线上的本地待处理操作重放测试。已被 ACK 包含的前缀应跳过,其余修改按顺序应用到私有草稿。
|
||||||
|
* 字段存在性、canvas 归属和 incarnation 都参与检查;后段冲突不能污染原 ACK 或操作账本。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,applyChanges,replayChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,applyChanges,replayChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts';
|
import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts';
|
||||||
import {replayPending} from '../../src/lib/canvas-runtime/reconcile.ts';
|
import {replayPending} from '../../src/lib/canvas-runtime/reconcile.ts';
|
||||||
|
// 使用内置类型校验重放结果,避免以空 registry 绕过元素约束。
|
||||||
const registry=createRegistry(builtinComponents());
|
const registry=createRegistry(builtinComponents());
|
||||||
|
// 构造带独立身份和归属的文本元素,可按场景替换 ID、canvas 或 incarnation。
|
||||||
const node=(id='a',ownerCanvasId='canvas',incarnation=1)=>({id,ownerCanvasId,incarnation,componentId:'text',dataVersion:1,structure:'node',data:{title:'original',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
const node=(id='a',ownerCanvasId='canvas',incarnation=1)=>({id,ownerCanvasId,incarnation,componentId:'text',dataVersion:1,structure:'node',data:{title:'original',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||||
|
// 每次返回新的基线文档,避免不同用例复用被修改的实例。
|
||||||
const base=()=>createDocument('project',[{id:'canvas',parent:null}],[node()]);
|
const base=()=>createDocument('project',[{id:'canvas',parent:null}],[node()]);
|
||||||
|
// 生成带前后值的标题修改,字段目标固定到 a 的第一代身份。
|
||||||
const title=(before,after)=>({kind:'field',target:{id:'a',incarnation:1},field:'data.title',before,after});
|
const title=(before,after)=>({kind:'field',target:{id:'a',incarnation:1},field:'data.title',before,after});
|
||||||
|
// 把各组修改登记为有序 pending 操作,并根据创建或既有对象提供存在性基线;这里只搭建重放输入。
|
||||||
function ledgerFor(groups){const ledger=createLedger();groups.forEach((changes,index)=>{const presence={};for(const change of changes)if(change.kind!=='canvas')presence[change.kind==='field'?change.target.id:change.id]=change.kind==='element'&&change.before===null?'absent':'exists';ledger.add({id:`op-${index}`,sequence:index+1,origin:'user',operationType:'document.change',operationSchemaVersion:1,canvasIds:['canvas'],dependencies:[],changes},presence);});return ledger.getSnapshot();}
|
function ledgerFor(groups){const ledger=createLedger();groups.forEach((changes,index)=>{const presence={};for(const change of changes)if(change.kind!=='canvas')presence[change.kind==='field'?change.target.id:change.id]=change.kind==='element'&&change.before===null?'absent':'exists';ledger.add({id:`op-${index}`,sequence:index+1,origin:'user',operationType:'document.change',operationSchemaVersion:1,canvasIds:['canvas'],dependencies:[],changes},presence);});return ledger.getSnapshot();}
|
||||||
|
|
||||||
|
// 分别从原始 ACK 和已确认第一步的 ACK 重放同一序列,最终标题都到 third,且输入 ACK 保持原值。
|
||||||
test('pending replay preserves sequential field baselines and skips a prefix already present in ACK',()=>{
|
test('pending replay preserves sequential field baselines and skips a prefix already present in ACK',()=>{
|
||||||
const original=base(),ledger=ledgerFor([[title('original','first'),title('first','second')],[title('second','third')]]);
|
const original=base(),ledger=ledgerFor([[title('original','first'),title('first','second')],[title('second','third')]]);
|
||||||
for(const acknowledged of [original,applyChanges(original,[title('original','first')])]){const result=replayPending(acknowledged,ledger,registry);assert.equal(result.elements.get('a').data.title,'third');assert.equal(acknowledged.elements.get('a').data.title,acknowledged===original?'original':'first');}
|
for(const acknowledged of [original,applyChanges(original,[title('original','first')])]){const result=replayPending(acknowledged,ledger,registry);assert.equal(result.elements.get('a').data.title,'third');assert.equal(acknowledged.elements.get('a').data.title,acknowledged===original?'original':'first');}
|
||||||
});
|
});
|
||||||
|
// 显式区分字段缺失与值为 null;先创建 null 字段再删除,并允许已完成删除的重复重放。
|
||||||
test('replay distinguishes missing and null fields while applying and skipping repeated deletes',()=>{
|
test('replay distinguishes missing and null fields while applying and skipping repeated deletes',()=>{
|
||||||
const original=base(),insert={kind:'field',target:{id:'a',incarnation:1},field:'data.optional',before:null,after:null,beforePresent:false,afterPresent:true},remove={...insert,beforePresent:true,afterPresent:false};
|
const original=base(),insert={kind:'field',target:{id:'a',incarnation:1},field:'data.optional',before:null,after:null,beforePresent:false,afterPresent:true},remove={...insert,beforePresent:true,afterPresent:false};
|
||||||
const added=replayPending(original,ledgerFor([[insert]]),registry);assert.ok(Object.hasOwn(added.elements.get('a').data,'optional'));assert.equal(added.elements.get('a').data.optional,null);
|
const added=replayPending(original,ledgerFor([[insert]]),registry);assert.ok(Object.hasOwn(added.elements.get('a').data,'optional'));assert.equal(added.elements.get('a').data.optional,null);
|
||||||
const removed=replayPending(original,ledgerFor([[insert],[remove],[remove]]),registry);assert.equal(Object.hasOwn(removed.elements.get('a').data,'optional'),false);
|
const removed=replayPending(original,ledgerFor([[insert],[remove],[remove]]),registry);assert.equal(Object.hasOwn(removed.elements.get('a').data,'optional'),false);
|
||||||
});
|
});
|
||||||
|
// 先迁移 owner 再修改子 canvas 内字段;即使值未变化,错误作用域和旧 incarnation 仍必须被拒绝。
|
||||||
test('a draft replays owner transitions before later scope checks and rejects stale identity even for equal values',()=>{
|
test('a draft replays owner transitions before later scope checks and rejects stale identity even for equal values',()=>{
|
||||||
const frame={...node('frame'),componentId:'frame'},canvases=[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}}],original=createDocument('project',canvases,[frame,node()]);
|
const frame={...node('frame'),componentId:'frame'},canvases=[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'frame'}}],original=createDocument('project',canvases,[frame,node()]);
|
||||||
const move={kind:'field',target:{id:'a',incarnation:1},field:'ownerCanvasId',before:'canvas',after:'child'};
|
const move={kind:'field',target:{id:'a',incarnation:1},field:'ownerCanvasId',before:'canvas',after:'child'};
|
||||||
@@ -25,9 +37,11 @@ test('a draft replays owner transitions before later scope checks and rejects st
|
|||||||
const relocated=createDocument('project',canvases,[frame,node('a','child')]);assert.throws(()=>replayPending(relocated,ledgerFor([[title('original','original')]]),registry),/operation_context_changed/);
|
const relocated=createDocument('project',canvases,[frame,node('a','child')]);assert.throws(()=>replayPending(relocated,ledgerFor([[title('original','original')]]),registry),/operation_context_changed/);
|
||||||
const restored=createDocument('project',[canvases[0]],[node('a','canvas',2)]);assert.throws(()=>replayPending(restored,ledgerFor([[title('original','original')]]),registry),/stale_incarnation/);
|
const restored=createDocument('project',[canvases[0]],[node('a','canvas',2)]);assert.throws(()=>replayPending(restored,ledgerFor([[title('original','original')]]),registry),/stale_incarnation/);
|
||||||
});
|
});
|
||||||
|
// 前序重放成功、后序前值冲突时丢弃整个私有草稿;原文档与账本不变,修正前值后才得到完整结果。
|
||||||
test('late conflicts discard the entire private replay draft and preserve the ACK and ledger',()=>{
|
test('late conflicts discard the entire private replay draft and preserve the ACK and ledger',()=>{
|
||||||
const original=base(),ledger=ledgerFor([[title('original','first')],[title('wrong','last')]]),before=JSON.stringify([...ledger.operations]);assert.throws(()=>replayPending(original,ledger,registry),/field_conflict/);assert.equal(original.elements.get('a').data.title,'original');assert.equal(JSON.stringify([...ledger.operations]),before);assert.equal(replayPending(original,ledgerFor([[title('original','first')],[title('first','last')]]),registry).elements.get('a').data.title,'last');
|
const original=base(),ledger=ledgerFor([[title('original','first')],[title('wrong','last')]]),before=JSON.stringify([...ledger.operations]);assert.throws(()=>replayPending(original,ledger,registry),/field_conflict/);assert.equal(original.elements.get('a').data.title,'original');assert.equal(JSON.stringify([...ledger.operations]),before);assert.equal(replayPending(original,ledgerFor([[title('original','first')],[title('first','last')]]),registry).elements.get('a').data.title,'last');
|
||||||
});
|
});
|
||||||
|
// 256 次创建后再删除并以新 incarnation 恢复一个对象,对比逐组 applyChanges 的元素和墓碑结果。
|
||||||
test('large replay preserves create/delete/restore lifecycles and conditional failures across operation boundaries',()=>{
|
test('large replay preserves create/delete/restore lifecycles and conditional failures across operation boundaries',()=>{
|
||||||
const original=base(),created=Array.from({length:256},(_,i)=>node(`new-${i}`)),create=created.map(after=>({kind:'element',id:after.id,before:null,after})),deleted=created[50],restored={...deleted,incarnation:2,data:{...deleted.data,title:'restored'}},groups=[create,[{kind:'element',id:deleted.id,before:deleted,after:null}],[{kind:'element',id:deleted.id,before:null,after:restored}]];
|
const original=base(),created=Array.from({length:256},(_,i)=>node(`new-${i}`)),create=created.map(after=>({kind:'element',id:after.id,before:null,after})),deleted=created[50],restored={...deleted,incarnation:2,data:{...deleted.data,title:'restored'}},groups=[create,[{kind:'element',id:deleted.id,before:deleted,after:null}],[{kind:'element',id:deleted.id,before:null,after:restored}]];
|
||||||
const result=replayPending(original,ledgerFor(groups),registry);let reference=original;for(const changes of groups)reference=applyChanges(reference,changes);assert.deepEqual([...result.elements],[...reference.elements]);assert.deepEqual([...result.tombstones],[...reference.tombstones]);assert.equal(result.tombstones.get(deleted.id),1);assert.equal(original.elements.size,1);
|
const result=replayPending(original,ledgerFor(groups),registry);let reference=original;for(const changes of groups)reference=applyChanges(reference,changes);assert.deepEqual([...result.elements],[...reference.elements]);assert.deepEqual([...result.tombstones],[...reference.tombstones]);assert.equal(result.tombstones.get(deleted.id),1);assert.equal(original.elements.size,1);
|
||||||
|
|||||||
@@ -1,38 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 恢复值编码与同步/协作式封存测试:JSON 语义、不可变缓存、所有权、深度限制和精确 UTF-8 计数必须同时成立。
|
||||||
|
* 未知 getter、toJSON、Proxy 和可变内建对象可能执行调用者逻辑,不能仅凭 Object.freeze 就跳过捕获。
|
||||||
|
* 协作式路径对安全内部数据按预算让出,对未知回调维持同步捕获顺序;全局探针均需恢复。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {freeze,validJson} from '../../src/lib/canvas-core/values.ts';
|
import {freeze,validJson} from '../../src/lib/canvas-core/values.ts';
|
||||||
import {encodeRecoveryValue,sealRecoveryImage,isSealedRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.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));
|
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',()=>{
|
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 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));
|
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',()=>{
|
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 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);
|
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',()=>{
|
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 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});
|
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 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']);
|
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',()=>{
|
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/);
|
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/);
|
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}));
|
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',()=>{
|
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/);
|
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()=>{
|
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 '动态😀';}})];
|
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);}
|
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()=>{
|
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 {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.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);
|
assert.ok(yields>beforeCoarse);assert.deepEqual(more.payload,large);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同一未知值的每次出现都独立求值;让出失败、过深数据及非法修订拒绝,不产生可用的半成品 image。
|
||||||
test('cooperative preparation evaluates every unsafe occurrence and rejects errors without an image',async()=>{
|
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 {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.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}}});
|
assert.deepEqual(image.payload,{left:{dynamic:{title:1}},right:{dynamic:{title:2}}});
|
||||||
let ticks=0;
|
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/);
|
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 nested=(n,leaf)=>{for(let i=0;i<n;i++)leaf=dataRecord({child:leaf});return leaf;};
|
||||||
const leaf=freeze({value:1});encodeRecoveryValue(leaf);
|
const leaf=freeze({value:1});encodeRecoveryValue(leaf);
|
||||||
await assert.rejects(sealRecoveryImageAsync(17,1,1,nested(64,leaf)),/invalid_recovery_payload/);
|
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/);
|
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()=>{
|
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 {dataArray}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.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;}
|
}finally{JSON.stringify=stringify;}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 记录未知 Proxy 的所有 trap,要求异步封存与同步封存的调用次序、次数和输出相同。
|
||||||
test('unknown proxies retain the synchronous codec trap order and callback count',async()=>{
|
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 {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.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()=>{
|
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 {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.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}}]);
|
assert.deepEqual(captured.payload,[{getValue:{value:1}},{getValue:{value:2}}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 生成首次进入 codec 的大元素数据,让测试能区分第一次规范化与已预热缓存的复用。
|
||||||
const ownedRecord=async()=>{
|
const ownedRecord=async()=>{
|
||||||
const {detachElementData}=await import('../../src/lib/canvas-core/element-data.ts');
|
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}))}});
|
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()=>{
|
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 {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||||
const element=await ownedRecord(),stringify=JSON.stringify;let strings=0,first,tick=0;
|
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;}
|
}finally{JSON.stringify=stringify;}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 准入前原型链已有 toJSON getter 时,即使数据由内部拥有也走同步捕获,调用次数与同步路径相同且不让出。
|
||||||
test('inherited toJSON accessors at admission keep detached elements on the synchronous capture path',async()=>{
|
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 {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;
|
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;}
|
}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()=>{
|
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 {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||||
const previous=Object.getOwnPropertyDescriptor(Array.prototype,'toJSON');
|
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;};
|
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()});
|
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{
|
try{
|
||||||
const expected=sealRecoveryImage(17,1,1,await make());cleanup();
|
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();}
|
}finally{cleanup();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 完整元素藏在深层包装下时,无论未缓存还是已缓存,均须计入内部深度并拒绝越界。
|
||||||
test('deferred detached subtrees enforce the depth bound both cold and after cache reuse',async()=>{
|
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 {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.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 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 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);
|
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 {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {sealRecoveryImage,sealedRecoveryByteLength} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
|
import {sealRecoveryImage,sealedRecoveryByteLength} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
|
||||||
import {serializeRecoveryImage} from '../../src/lib/canvas-runtime/recovery-json.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;}};};
|
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()=>{
|
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 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)));
|
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()=>{
|
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());
|
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');
|
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()=>{
|
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 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);
|
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 {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,applyChanges} from '../../src/lib/canvas-core/document.ts';
|
import {createDocument,applyChanges} from '../../src/lib/canvas-core/document.ts';
|
||||||
import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts';
|
import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts';
|
||||||
import {encodeRecovery} from '../../src/lib/canvas-runtime/recovery.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')]);
|
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()}});
|
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',()=>{
|
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);
|
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',()=>{
|
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);
|
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',()=>{
|
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);
|
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 {readMap,freeze} from '../../src/lib/canvas-core/values.ts';
|
||||||
import * as publicCore from '../../src/lib/canvas-core/index.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',()=>{
|
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');
|
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;
|
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);
|
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]);
|
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',()=>{
|
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 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);
|
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);
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 资源引用扫描测试:工作文档、ACK、历史及显式 owner 都可能持有资源;无法解释的数据必须让扫描 complete=false。
|
||||||
|
* 缓存只复用不可变历史范围的结构事实,不能缓存外部可变表成员,也不能忽略当前 registry 的资源字段定义。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,describeHistoryTransition} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,describeHistoryTransition} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {createResourcePinScan,resourcePins} from '../../src/lib/canvas-runtime/resources.ts';
|
import {createResourcePinScan,resourcePins} from '../../src/lib/canvas-runtime/resources.ts';
|
||||||
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
|
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
|
||||||
|
// 准备内置定义、空历史及图片/文本工厂;doc 总是建立独立文档,用于区分 working 与 ACK owner。
|
||||||
const registry=createRegistry(builtinComponents()),empty={undo:[],redo:[],entries:new Map()},node=(id,asset)=>({id,incarnation:1,ownerCanvasId:'root',componentId:asset?'image':'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...(asset?{assetId:asset}:{})}}),doc=(records=[])=>createDocument('p',[{id:'root',parent:null}],records);
|
const registry=createRegistry(builtinComponents()),empty={undo:[],redo:[],entries:new Map()},node=(id,asset)=>({id,incarnation:1,ownerCanvasId:'root',componentId:asset?'image':'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...(asset?{assetId:asset}:{})}}),doc=(records=[])=>createDocument('p',[{id:'root',parent:null}],records);
|
||||||
|
// 用 Proxy 计数证明不可变历史范围只准备一次;working/ACK 分别扫描,最终 pin 保留各自来源而不混淆。
|
||||||
test('immutable history ranges are reused across retention passes while working and ACK keep separate owners',()=>{
|
test('immutable history ranges are reused across retention passes while working and ACK keep separate owners',()=>{
|
||||||
const entry={id:'h',label:'h',changes:[{kind:'element',id:'past',before:null,after:node('past','historical')}],operationIds:[]},raw=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1);let reads=0;const transition=new Proxy(raw,{get(target,key,receiver){if(key==='entries')reads++;return Reflect.get(target,key,receiver);}}),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])},scan=createResourcePinScan(empty,ledger,registry),preparationReads=reads;assert.ok(preparationReads>0);scan(doc([node('text')]));scan(doc([node('remote','ack')]));assert.equal(reads,preparationReads);
|
const entry={id:'h',label:'h',changes:[{kind:'element',id:'past',before:null,after:node('past','historical')}],operationIds:[]},raw=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1);let reads=0;const transition=new Proxy(raw,{get(target,key,receiver){if(key==='entries')reads++;return Reflect.get(target,key,receiver);}}),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])},scan=createResourcePinScan(empty,ledger,registry),preparationReads=reads;assert.ok(preparationReads>0);scan(doc([node('text')]));scan(doc([node('remote','ack')]));assert.equal(reads,preparationReads);
|
||||||
const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,0);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']);
|
const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,0);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']);
|
||||||
});
|
});
|
||||||
|
// 历史字段修改可从已解决创建记录推断类型,并按 history/显式 owner 顺序保留资源;缺少类型证据时改为不完整扫描。
|
||||||
test('historical data fields retain schemas from resolved creations, conservative fallback pins and explicit owner order',()=>{
|
test('historical data fields retain schemas from resolved creations, conservative fallback pins and explicit owner order',()=>{
|
||||||
const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'old',after:'new'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',label:'h',changes:[field],operationIds:[]}]])},ledger={operations:new Map([['resolved',{phase:'resolved',intent:{id:'resolved',sequence:1,operationType:'document.change',changes:[{kind:'element',id:'deleted',before:null,after:node('deleted','unreferenced')}]} }]])},snapshot={document:doc(),history},result=resourcePins(snapshot,ledger,registry,new Map([['explicit',['old','extra']]]));assert.equal(result.complete,true);assert.deepEqual([...result.pins].map(([id,owners])=>[id,[...owners]]),[['old',['history:h','explicit']],['new',['history:h']],['extra',['explicit']]]);ledger.operations.clear();assert.equal(resourcePins(snapshot,ledger,registry,new Map()).complete,false);
|
const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'old',after:'new'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',label:'h',changes:[field],operationIds:[]}]])},ledger={operations:new Map([['resolved',{phase:'resolved',intent:{id:'resolved',sequence:1,operationType:'document.change',changes:[{kind:'element',id:'deleted',before:null,after:node('deleted','unreferenced')}]} }]])},snapshot={document:doc(),history},result=resourcePins(snapshot,ledger,registry,new Map([['explicit',['old','extra']]]));assert.equal(result.complete,true);assert.deepEqual([...result.pins].map(([id,owners])=>[id,[...owners]]),[['old',['history:h','explicit']],['new',['history:h']],['extra',['explicit']]]);ledger.operations.clear();assert.equal(resourcePins(snapshot,ledger,registry,new Map()).complete,false);
|
||||||
});
|
});
|
||||||
|
// 新扫描必须看到可变 history.entries 后加的成员;即使已解决操作没有资源字段,损坏的 historyTransition 也不能跳过校验。
|
||||||
test('new passes reread mutable input membership and still reject malformed resolved evidence with no resource fields',()=>{
|
test('new passes reread mutable input membership and still reject malformed resolved evidence with no resource fields',()=>{
|
||||||
const history={undo:[],redo:[],entries:new Map()},ledger={operations:new Map()},snapshot={document:doc(),history};assert.equal(resourcePins(snapshot,ledger,registry,new Map()).pins.size,0);history.entries.set('h',{id:'h',label:'h',changes:[{kind:'element',id:'image',before:null,after:node('image','late')}],operationIds:[]});assert.ok(resourcePins(snapshot,ledger,registry,new Map()).pins.has('late'));ledger.operations.set('bad',{phase:'resolved',intent:{id:'bad',sequence:1,changes:[],historyTransition:{version:1,rootRevision:-1,before:{undo:[],redo:[]},after:{undo:[],redo:[]},entries:[]}}});assert.throws(()=>resourcePins(snapshot,ledger,registry,new Map()),/invalid_history_transition/);
|
const history={undo:[],redo:[],entries:new Map()},ledger={operations:new Map()},snapshot={document:doc(),history};assert.equal(resourcePins(snapshot,ledger,registry,new Map()).pins.size,0);history.entries.set('h',{id:'h',label:'h',changes:[{kind:'element',id:'image',before:null,after:node('image','late')}],operationIds:[]});assert.ok(resourcePins(snapshot,ledger,registry,new Map()).pins.has('late'));ledger.operations.set('bad',{phase:'resolved',intent:{id:'bad',sequence:1,changes:[],historyTransition:{version:1,rootRevision:-1,before:{undo:[],redo:[]},after:{undo:[],redo:[]},entries:[]}}});assert.throws(()=>resourcePins(snapshot,ledger,registry,new Map()),/invalid_history_transition/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 同一缓存历史范围配合不同 registry 扫描时,分别使用当前 image.resourceFields,不能沿用上次 registry 的资源提取结果。
|
||||||
test('cached immutable transition ranges still use each current registry resource definition',()=>{
|
test('cached immutable transition ranges still use each current registry resource definition',()=>{
|
||||||
const element={...node('historical','first'),data:{...node('historical','first').data,otherAsset:'second'}},entry={id:'h',label:'h',changes:[{kind:'element',id:element.id,before:null,after:element}],operationIds:[]};
|
const element={...node('historical','first'),data:{...node('historical','first').data,otherAsset:'second'}},entry={id:'h',label:'h',changes:[{kind:'element',id:element.id,before:null,after:element}],operationIds:[]};
|
||||||
const transition=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])};
|
const transition=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])};
|
||||||
|
|||||||
@@ -1,40 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 资源显示订阅的独立生命周期回归。通过手动Promise控制读取完成顺序,检查逐项显示、部分失败重试、替换取消及句柄释放。
|
||||||
|
* blob字符串仅为测试URL,release计数由桩实现;不运行真实资源解码或浏览器URL仓库。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createResourceView } from '../../src/lib/canvas-bindings/resource-view.ts';
|
import { createResourceView } from '../../src/lib/canvas-bindings/resource-view.ts';
|
||||||
|
/** 暴露resolve/reject控制每次资源读取,模拟晚到成功与读取失败。 */
|
||||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||||
|
/** 等一个事件循环轮次,让资源Promise与通知完成,避免用固定墙钟延迟猜时序。 */
|
||||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
/** 收集resolve请求、onChange、onError及release序号;返回view与手动完成入口。 */
|
||||||
function fixture() {
|
function fixture() {
|
||||||
const requests = [], updates = [], errors = [], released = [];
|
const requests = [], updates = [], errors = [], released = [];
|
||||||
const view = createResourceView({ resolve(resource, signal) { const gate = deferred(); requests.push({ resource, signal, gate }); return gate.promise; }, onChange(sources, failures) { updates.push({ sources, failures }); }, onError: error => errors.push(error) });
|
const view = createResourceView({ resolve(resource, signal) { const gate = deferred(); requests.push({ resource, signal, gate }); return gate.promise; }, onChange(sources, failures) { updates.push({ sources, failures }); }, onError: error => errors.push(error) });
|
||||||
|
/** 解析指定请求为URL句柄,release幂等记录一次,便于核对替换/卸载是否漏清理或重复释放。 */
|
||||||
const finish = (index, url = `blob:${index}`) => { let disposed = false; requests[index].gate.resolve({ url, release() { if (!disposed) { disposed = true; released.push(index); } } }); };
|
const finish = (index, url = `blob:${index}`) => { let disposed = false; requests[index].gate.resolve({ url, release() { if (!disposed) { disposed = true; released.push(index); } } }); };
|
||||||
return { view, requests, updates, errors, released, finish, latest: () => updates.at(-1) };
|
return { view, requests, updates, errors, released, finish, latest: () => updates.at(-1) };
|
||||||
}
|
}
|
||||||
|
// r1先就绪就显示,不等待r2;新增r3不重读旧资源或释放已就绪句柄,最终dispose各释放一次。
|
||||||
test('ready images display individually; adding a node preserves existing handles and reads', async () => {
|
test('ready images display individually; adding a node preserves existing handles and reads', async () => {
|
||||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); await tick();
|
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); await tick();
|
||||||
assert.deepEqual([...f.latest().sources], [['a', { resourceId: 'r1', url: 'blob:0' }]]);
|
assert.deepEqual([...f.latest().sources], [['a', { resourceId: 'r1', url: 'blob:0' }]]);
|
||||||
f.view.sync([['a', 'r1'], ['b', 'r2'], ['c', 'r3']]); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r3']); assert.deepEqual(f.released, []);
|
f.view.sync([['a', 'r1'], ['b', 'r2'], ['c', 'r3']]); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r3']); assert.deepEqual(f.released, []);
|
||||||
f.finish(2); await tick(); assert.equal(f.latest().sources.size, 2); f.finish(1); await tick(); assert.equal(f.latest().sources.size, 3); f.view.dispose(); assert.deepEqual(f.released.sort(), [0, 1, 2]);
|
f.finish(2); await tick(); assert.equal(f.latest().sources.size, 2); f.finish(1); await tick(); assert.equal(f.latest().sources.size, 3); f.view.dispose(); assert.deepEqual(f.released.sort(), [0, 1, 2]);
|
||||||
});
|
});
|
||||||
|
// r2失败时r1仍在,retry只请求r2并保留r1句柄;成功后failures清零。
|
||||||
test('a failed reader does not hide other ready images and retry touches only failures', async () => {
|
test('a failed reader does not hide other ready images and retry touches only failures', async () => {
|
||||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.requests[1].gate.reject(Error('read_failed')); await tick();
|
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.requests[1].gate.reject(Error('read_failed')); await tick();
|
||||||
assert.equal(f.latest().sources.get('a').url, 'blob:0'); assert.equal(f.latest().failures, 1); assert.equal(f.errors.length, 1);
|
assert.equal(f.latest().sources.get('a').url, 'blob:0'); assert.equal(f.latest().failures, 1); assert.equal(f.errors.length, 1);
|
||||||
f.view.retry(); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r2']); assert.deepEqual(f.released, []);
|
f.view.retry(); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r2']); assert.deepEqual(f.released, []);
|
||||||
f.finish(2); await tick(); assert.equal(f.latest().failures, 0); assert.equal(f.latest().sources.size, 2); f.view.dispose();
|
f.finish(2); await tick(); assert.equal(f.latest().failures, 0); assert.equal(f.latest().sources.size, 2); f.view.dispose();
|
||||||
});
|
});
|
||||||
|
// 同节点由old切new,旧signal被取消;旧实现忽略abort后才返回,也只释放旧句柄,不能覆盖新URL。
|
||||||
test('replacement aborts the old reader and fences a late result that ignored cancellation', async () => {
|
test('replacement aborts the old reader and fences a late result that ignored cancellation', async () => {
|
||||||
const f = fixture(); f.view.sync([['a', 'old']]); await tick(); f.view.sync([['a', 'new']]); await tick(); assert.equal(f.requests[0].signal.aborted, true);
|
const f = fixture(); f.view.sync([['a', 'old']]); await tick(); f.view.sync([['a', 'new']]); await tick(); assert.equal(f.requests[0].signal.aborted, true);
|
||||||
f.finish(1, 'blob:new'); await tick(); f.finish(0, 'blob:old'); await tick();
|
f.finish(1, 'blob:new'); await tick(); f.finish(0, 'blob:old'); await tick();
|
||||||
assert.deepEqual(f.latest().sources.get('a'), { resourceId: 'new', url: 'blob:new' }); assert.deepEqual(f.released, [0]); f.view.dispose(); assert.deepEqual(f.released, [0, 1]);
|
assert.deepEqual(f.latest().sources.get('a'), { resourceId: 'new', url: 'blob:new' }); assert.deepEqual(f.released, [0]); f.view.dispose(); assert.deepEqual(f.released, [0, 1]);
|
||||||
});
|
});
|
||||||
|
// 删a只释放a,b保持;两次dispose只各清一次,已dispose的retry明确拒绝。
|
||||||
test('removing one loaded node retains the other; repeated disposal does not release twice', async () => {
|
test('removing one loaded node retains the other; repeated disposal does not release twice', async () => {
|
||||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.finish(1); await tick();
|
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.finish(1); await tick();
|
||||||
f.view.sync([['b', 'r2']]); assert.deepEqual(f.released, [0]); assert.equal(f.latest().sources.get('b').url, 'blob:1');
|
f.view.sync([['b', 'r2']]); assert.deepEqual(f.released, [0]); assert.equal(f.latest().sources.get('b').url, 'blob:1');
|
||||||
f.view.dispose(); f.view.dispose(); assert.deepEqual(f.released, [0, 1]); assert.equal(f.latest().sources.size, 0); assert.throws(() => f.view.retry(), { code: 'resource_view_disposed' });
|
f.view.dispose(); f.view.dispose(); assert.deepEqual(f.released, [0, 1]); assert.equal(f.latest().sources.size, 0); assert.throws(() => f.view.retry(), { code: 'resource_view_disposed' });
|
||||||
});
|
});
|
||||||
|
// dispose后资源才到,仍释放句柄但不再onChange/onError,避免旧视图收到取消噪音。
|
||||||
test('disposal cleans late handles without publishing into a retired view or reporting cancellation as an error', async () => {
|
test('disposal cleans late handles without publishing into a retired view or reporting cancellation as an error', async () => {
|
||||||
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.view.dispose(); const count = f.updates.length; f.finish(0); await tick();
|
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.view.dispose(); const count = f.updates.length; f.finish(0); await tick();
|
||||||
assert.equal(f.updates.length, count); assert.deepEqual(f.released, [0]); assert.deepEqual(f.errors, []);
|
assert.equal(f.updates.length, count); assert.deepEqual(f.released, [0]); assert.deepEqual(f.errors, []);
|
||||||
});
|
});
|
||||||
|
// 重复节点ID列表拒绝前不改现有订阅;随后同r1同步不新增读取或释放已用资源。
|
||||||
test('invalid target lists reject before changing active subscriptions', async () => {
|
test('invalid target lists reject before changing active subscriptions', async () => {
|
||||||
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.finish(0); await tick();
|
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.finish(0); await tick();
|
||||||
assert.throws(() => f.view.sync([['a', 'r2'], ['a', 'r3']]), { code: 'invalid_resource_view' }); assert.deepEqual(f.released, []);
|
assert.throws(() => f.view.sync([['a', 'r2'], ['a', 'r3']]), { code: 'invalid_resource_view' }); assert.deepEqual(f.released, []);
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 项目引擎资源生命周期测试:本地图片先发布,文件及账本持久化后上传,依赖文档操作等待资源确认。
|
||||||
|
* 默认尺寸授权、解码元数据和显式 fitImage 各有独立语义;迟到解码不能覆盖用户调整或绑定到旧元素身份。
|
||||||
|
* 本地回收需综合 working/history/ACK/任务/显示引用,先标记、再等待宽限期,并将物理删除与恢复墓碑一同保存。
|
||||||
|
* fixture 使用内存端口,验证引擎协调及故障处理;不证明真实数据库的事务隔离或浏览器实际解码能力。
|
||||||
|
*/
|
||||||
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
|
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -6,26 +12,38 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
|||||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||||
import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
|
import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
|
||||||
|
|
||||||
|
// 内置 registry 提供图片资源字段与几何能力;固定作用域让重开时读取同一个项目的持久化 image。
|
||||||
const registry = createRegistry(builtinComponents());
|
const registry = createRegistry(builtinComponents());
|
||||||
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' };
|
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' };
|
||||||
const geometry = { x: 0, y: 0, width: 100, height: 100, rotation: 0 };
|
const geometry = { x: 0, y: 0, width: 100, height: 100, rotation: 0 };
|
||||||
|
// 构造独立 image 元素;允许无 assetId 的基线节点 a 充当与上传无关的编辑目标。
|
||||||
const node = (id, data = {}) => ({ id, incarnation: 1, structure: 'node', componentId: 'image', dataVersion: 1, ownerCanvasId: 'canvas', geometry, data: { title: id, text: '', ...data } });
|
const node = (id, data = {}) => ({ id, incarnation: 1, structure: 'node', componentId: 'image', dataVersion: 1, ownerCanvasId: 'canvas', geometry, data: { title: id, text: '', ...data } });
|
||||||
|
// 初始只有 canvas 和 a,新增 picture 可通过元素数量及服务端写次数区分,避免把恢复误当再次创建。
|
||||||
const original = createDocument('project', [{ id: 'canvas', parent: null }], [node('a')]);
|
const original = createDocument('project', [{ id: 'canvas', parent: null }], [node('a')]);
|
||||||
|
// 可手动完成的 Promise 屏障,用于停住解码、上传、文件读取或删除落盘,控制迟到结果的时序。
|
||||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
||||||
|
// 最多轮询 100 次并让出定时器,等待特定异步阶段出现;超时直接失败,不无限等待。
|
||||||
async function until(predicate) { for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 1)); } assert.fail('condition did not become true'); }
|
async function until(predicate) { for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 1)); } assert.fail('condition did not become true'); }
|
||||||
|
// 为每个用例创建文件/提交/回执表和假服务端;storage 与服务端保留状态以支持关闭后重新打开引擎。
|
||||||
function fixture() {
|
function fixture() {
|
||||||
let image = null, owner, counter = 0, server = original, version = 1;
|
let image = null, owner, counter = 0, server = original, version = 1;
|
||||||
const files = new Map(), batches = [], uploads = [], writes = [], receipts = new Map(), assetReceipts = new Map();
|
const files = new Map(), batches = [], uploads = [], writes = [], receipts = new Map(), assetReceipts = new Map();
|
||||||
const storage = {
|
const storage = {
|
||||||
fileDeletion: 'atomic-v1',
|
fileDeletion: 'atomic-v1',
|
||||||
|
// 单 owner 领取;release 同时清空当前 owner 并将旧 handle 标为 inactive,便于引擎关闭后再开。
|
||||||
async claim(scope) { assert.equal(owner, undefined); const handle = { scope, fence: 1, active: true, async release() { owner = undefined; handle.active = false; } }; owner = handle; return handle; },
|
async claim(scope) { assert.equal(owner, undefined); const handle = { scope, fence: 1, active: true, async release() { owner = undefined; handle.active = false; } }; owner = handle; return handle; },
|
||||||
|
// 克隆先前 image,使新引擎从持久化数据重建而不是共享旧快照对象。
|
||||||
async recover() { return structuredClone(image); },
|
async recover() { return structuredClone(image); },
|
||||||
|
// 模拟 atomic-v1 文件提交:新增文件 ID 不能覆盖,删除与 image 在同一调用保存;真实回滚不由此内存实现证明。
|
||||||
async commit(_leader, batch) { for (const file of batch.files ?? []) { assert.equal(files.has(file.id), false); files.set(file.id, file.bytes); } for(const id of batch.deleteFiles??[])files.delete(id); image = structuredClone(batch.image); batches.push({ ...batch, image }); return { durableRootRevision: image.rootRevision }; },
|
async commit(_leader, batch) { for (const file of batch.files ?? []) { assert.equal(files.has(file.id), false); files.set(file.id, file.bytes); } for(const id of batch.deleteFiles??[])files.delete(id); image = structuredClone(batch.image); batches.push({ ...batch, image }); return { durableRootRevision: image.rootRevision }; },
|
||||||
async readFile(_leader, id) { return files.get(id); }, close() {},
|
// 按资源 ID 取原始 Blob;用例会替换它来制造磁盘错误、共享读取及迟到结果。
|
||||||
|
async readFile(_leader, id) { return files.get(id); }, /* 内存存储无外部句柄;owner 由 release 归还。 */ close() {},
|
||||||
};
|
};
|
||||||
const assets = {
|
const assets = {
|
||||||
sourceURL: id => `https://assets.example/${id}`, displayURL: id => `/assets/${id}`,
|
sourceURL: id => `https://assets.example/${id}`, displayURL: id => `/assets/${id}`,
|
||||||
|
// 上传前断言文件已落盘、image 中已有 dispatch-marked 证据;随后保存可查询的成功资产回执。
|
||||||
async upload(request, bytes) {
|
async upload(request, bytes) {
|
||||||
|
// 资源字节和 dispatch 证据都必须在上传前可恢复;这两条断言验证持久化屏障的顺序。
|
||||||
const payload = JSON.parse(request.body); assert.equal(await files.get(payload.resource_id).text(), await bytes.text());
|
const payload = JSON.parse(request.body); assert.equal(await files.get(payload.resource_id).text(), await bytes.text());
|
||||||
assert.ok(image.payload.runtime.ledger.requests.some(([id, row]) => id === request.id && row.phase === 'dispatch-marked'));
|
assert.ok(image.payload.runtime.ledger.requests.some(([id, row]) => id === request.id && row.phase === 'dispatch-marked'));
|
||||||
uploads.push(request);
|
uploads.push(request);
|
||||||
@@ -33,20 +51,29 @@ function fixture() {
|
|||||||
assetReceipts.set(request.id, asset);
|
assetReceipts.set(request.id, asset);
|
||||||
return { outcome: 'applied', canonical: asset };
|
return { outcome: 'applied', canonical: asset };
|
||||||
},
|
},
|
||||||
|
// 上传回执按原 requestId 查询;存在则返回 applied 元数据,不存在仍为 unresolved。
|
||||||
async query(request) { return assetReceipts.has(request.id) ? { outcome: 'applied', canonical: assetReceipts.get(request.id) } : { outcome: 'unresolved' }; },
|
async query(request) { return assetReceipts.has(request.id) ? { outcome: 'applied', canonical: assetReceipts.get(request.id) } : { outcome: 'unresolved' }; },
|
||||||
|
// 此 fixture 直接交出 canonical,由引擎继续验证资源身份;Foundry wire 回执校验在 adapter 专项测试中覆盖。
|
||||||
accept: (_request, status) => status.canonical,
|
accept: (_request, status) => status.canonical,
|
||||||
};
|
};
|
||||||
const persistence = {
|
const persistence = {
|
||||||
|
// 恢复读取当前假服务端文档和 canvas 版本,不能直接拿本地 working 代替 ACK。
|
||||||
async read(){return{document:server,versions:new Map([['canvas',version]])};},
|
async read(){return{document:server,versions:new Map([['canvas',version]])};},
|
||||||
|
// 简化请求把完整意图放入 payload,并读取参与 canvas 的 ACK 版本,不进行真实 wire 压缩。
|
||||||
compile(intent, id, snapshot) { return { payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])) }; },
|
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 回执,供后续接纳恢复使用。
|
||||||
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 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' }; },
|
async query(request) { return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved' }; },
|
||||||
|
// 成功文档请求取其保存的 document/versions,不凭当前本地文档猜测服务端状态。
|
||||||
canonical(request) { return receipts.get(request.id); },
|
canonical(request) { return receipts.get(request.id); },
|
||||||
};
|
};
|
||||||
return { storage, assets, persistence, files, batches, uploads, writes, assetReceipts, image: () => image,
|
return { storage, assets, persistence, files, batches, uploads, writes, assetReceipts, image: () => image,
|
||||||
|
// 以相同存储和服务端端口打开新引擎;extra 可替换时钟、解码器、策略或历史容量。
|
||||||
open: extra => openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, assets, persistence, createId: () => `id-${++counter}`, ...extra }),
|
open: extra => openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, assets, persistence, createId: () => `id-${++counter}`, ...extra }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// stageFiles 在同一同步事务创建图片与资源工作;defaultSize 决定是否授予后续自动尺寸修正权限。
|
||||||
function addImage(engine, id = 'picture', defaultSize = false) {
|
function addImage(engine, id = 'picture', defaultSize = false) {
|
||||||
let resource;
|
let resource;
|
||||||
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }), defaultSize }], ([ref], tx) => {
|
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }), defaultSize }], ([ref], tx) => {
|
||||||
@@ -56,28 +83,33 @@ function addImage(engine, id = 'picture', defaultSize = false) {
|
|||||||
assert.equal(result.status, 'applied'); return resource;
|
assert.equal(result.status, 'applied'); return resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解码前先移动和改名,自动尺寸结果随后回写创建历史且不增加 undo 单元;重开后改名和移动可分别撤销,尺寸随创建保留,解码和上传均只一次。
|
||||||
test('automatic image sizing publishes with creation history across intervening edits and survives reopening', async () => {
|
test('automatic image sizing publishes with creation history across intervening edits and survives reopening', async () => {
|
||||||
const f = fixture(), gate = deferred(), entered = deferred(); let calls = 0;
|
const f = fixture(), gate = deferred(), entered = deferred(); let calls = 0;
|
||||||
const images = { decode: async () => { calls++; entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } };
|
const images = { decode: async () => { calls++; entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } };
|
||||||
let engine = await f.open({ images });
|
let engine = await f.open({ images });
|
||||||
try {
|
try {
|
||||||
const ref = addImage(engine, 'picture', true); await entered.promise;
|
const ref = addImage(engine, 'picture', true); await entered.promise;
|
||||||
|
// 订阅只记录尺寸已经生效的发布,随后核对 grant 与创建历史是否在同一 Root 中同步变化。
|
||||||
const observations = [];
|
const observations = [];
|
||||||
const unsubscribe = engine.editor.subscribe((_commit, snapshot) => { if (snapshot.document.elements.get('picture')?.geometry.height === 50) observations.push(snapshot); });
|
const unsubscribe = engine.editor.subscribe((_commit, snapshot) => { if (snapshot.document.elements.get('picture')?.geometry.height === 50) observations.push(snapshot); });
|
||||||
const target = { id: 'picture', incarnation: 1 };
|
const target = { id: 'picture', incarnation: 1 };
|
||||||
engine.editor.dispatch({ type: 'element.geometry', target, patch: { x: 70 } });
|
engine.editor.dispatch({ type: 'element.geometry', target, patch: { x: 70 } });
|
||||||
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed before decode' } });
|
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed before decode' } });
|
||||||
|
// 记录解码前栈内容;完成后必须还是这几个历史 ID,尺寸补偿不能另塞一个用户撤销条目。
|
||||||
const undo = [...engine.getSnapshot().history.undo]; gate.resolve(); await engine.settle();
|
const undo = [...engine.getSnapshot().history.undo]; gate.resolve(); await engine.settle();
|
||||||
const root = engine.getSnapshot(); assert.equal(root.document.elements.get('picture').geometry.height, 50);
|
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.document.elements.get('picture').geometry.x, 70); assert.deepEqual(root.history.undo, undo);
|
||||||
assert.equal(root.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
assert.equal(root.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
||||||
assert.ok(observations.length > 0);
|
assert.ok(observations.length > 0);
|
||||||
|
// 不能先发尺寸、后补 history:任何被观察到的生效快照都要具备对应授权终态与补偿证据。
|
||||||
for (const snapshot of observations) {
|
for (const snapshot of observations) {
|
||||||
assert.equal(snapshot.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
assert.equal(snapshot.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
||||||
assert.equal(snapshot.history.entries.get(undo[0]).changes[0].after.geometry.height, 50);
|
assert.equal(snapshot.history.entries.get(undo[0]).changes[0].after.geometry.height, 50);
|
||||||
assert.equal([...snapshot.operations.values()].some(intent => intent.historyEffect), true);
|
assert.equal([...snapshot.operations.values()].some(intent => intent.historyEffect), true);
|
||||||
}
|
}
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
// 自动尺寸是 asset-resolution 效果,关联历史证据;资源含新字段时不能把 image 格式降级为旧版本。
|
||||||
const effect = [...root.operations.values()].find(intent => intent.historyEffect);
|
const effect = [...root.operations.values()].find(intent => intent.historyEffect);
|
||||||
assert.equal(effect.origin, 'asset-resolution'); assert.equal(effect.historyTransition.entries.length, 2);
|
assert.equal(effect.origin, 'asset-resolution'); assert.equal(effect.historyTransition.entries.length, 2);
|
||||||
assert.equal(f.image().formatVersion, 6); decodeRecovery(f.image(), registry);
|
assert.equal(f.image().formatVersion, 6); decodeRecovery(f.image(), registry);
|
||||||
@@ -95,6 +127,7 @@ test('automatic image sizing publishes with creation history across intervening
|
|||||||
} finally { gate.resolve(); await engine.close(); }
|
} finally { gate.resolve(); await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 用户已提交 resize 即永久撤销默认尺寸授权;即使在解码完成前 undo resize,也不能重新授权自动调整。
|
||||||
test('a committed user resize permanently revokes default sizing even if undone before decoding', async () => {
|
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 }; } } });
|
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 {
|
try {
|
||||||
@@ -108,6 +141,7 @@ test('a committed user resize permanently revokes default sizing even if undone
|
|||||||
} finally { gate.resolve(); await engine.close(); }
|
} finally { gate.resolve(); await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 关闭时解码尚未完成,持久化保留 pending grant 和源字节;重开用新解码器继续修正,创建仍是一个历史单元。
|
||||||
test('a pending sizing grant and source bytes survive closing before decode completion', async () => {
|
test('a pending sizing grant and source bytes survive closing before decode completion', async () => {
|
||||||
const f = fixture(), gate = deferred(), entered = deferred(); let engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
const f = fixture(), gate = deferred(), entered = deferred(); let engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
||||||
const ref = addImage(engine, 'picture', true); await entered.promise;
|
const ref = addImage(engine, 'picture', true); await entered.promise;
|
||||||
@@ -118,6 +152,7 @@ test('a pending sizing grant and source bytes survive closing before decode comp
|
|||||||
finally { await engine.close(); }
|
finally { await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 手势预览期间暂不发布默认尺寸;提交移动后再修正高度,保留移动位置,undo 移动不会撤销尺寸补偿。
|
||||||
test('default sizing waits for a geometry gesture to finish and retains its committed movement', async () => {
|
test('default sizing waits for a geometry gesture to finish and retains its committed movement', async () => {
|
||||||
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
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 {
|
try {
|
||||||
@@ -134,6 +169,7 @@ test('default sizing waits for a geometry gesture to finish and retains its comm
|
|||||||
} finally { gate.resolve(); await engine.close(); }
|
} finally { gate.resolve(); await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 图片被换成另一资源后,旧 grant 撤销;旧解码器迟到完成不能更改当前几何。
|
||||||
test('replacing an image resource revokes its pending grant and a late decoder cannot change geometry', async () => {
|
test('replacing an image resource revokes its pending grant and a late decoder cannot change geometry', async () => {
|
||||||
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
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 {
|
try {
|
||||||
@@ -145,6 +181,7 @@ test('replacing an image resource revokes its pending grant and a late decoder c
|
|||||||
} finally { gate.resolve(); await engine.close(); }
|
} finally { gate.resolve(); await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 策略拒绝自动 geometry 命令时保留 blocked 授权;显式 retry 消费原 grant,仍只有一个创建历史和一次上传。
|
||||||
test('a denied automatic resize records a recoverable block and explicit retry consumes the original grant', async () => {
|
test('a denied automatic resize records a recoverable block and explicit retry consumes the original grant', async () => {
|
||||||
const f = fixture(); let denied = true;
|
const f = fixture(); let denied = true;
|
||||||
const engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) }, policy(command) { return command.type === 'element.geometry' && denied ? { enabled: false, code: 'forbidden', targets: ['picture'] } : { enabled: true }; } });
|
const engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) }, policy(command) { return command.type === 'element.geometry' && denied ? { enabled: false, code: 'forbidden', targets: ['picture'] } : { enabled: true }; } });
|
||||||
@@ -159,6 +196,7 @@ test('a denied automatic resize records a recoverable block and explicit retry c
|
|||||||
} finally { await engine.close(); }
|
} finally { await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未来版本 sizing 作为不透明数据保留,资源扫描不再声称完整;无关改名仍可保存,不能把未来授权当已知规则运行。
|
||||||
test('future default-sizing metadata stays opaque, blocks GC and permits unrelated editing', async () => {
|
test('future default-sizing metadata stays opaque, blocks GC and permits unrelated editing', async () => {
|
||||||
const f = fixture(); let engine = await f.open(); const ref = addImage(engine); await engine.settle(); await engine.close();
|
const f = fixture(); let engine = await f.open(); const ref = addImage(engine); await engine.settle(); await engine.close();
|
||||||
const future = { version: 2, opaque: { reference: 'keep' } }; f.image().formatVersion = 6; f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing = future;
|
const future = { version: 2, opaque: { reference: 'keep' } }; f.image().formatVersion = 6; f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing = future;
|
||||||
@@ -172,6 +210,7 @@ test('future default-sizing metadata stays opaque, blocks GC and permits unrelat
|
|||||||
} finally { await engine.close(); }
|
} finally { await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// stageFiles 立即显示元素与历史,字节和恢复状态同批保存;上传挂起时无关编辑可先同步,复制/撤销复用资源不重复上传。
|
||||||
test('pending images publish immediately, bytes share the durable cut, and independent canvas edits overtake upload', async () => {
|
test('pending images publish immediately, bytes share the durable cut, and independent canvas edits overtake upload', async () => {
|
||||||
const f = fixture(), gate = deferred(), upload = f.assets.upload;
|
const f = fixture(), gate = deferred(), upload = f.assets.upload;
|
||||||
f.assets.upload = async (...args) => { await gate.promise; return upload(...args); };
|
f.assets.upload = async (...args) => { await gate.promise; return upload(...args); };
|
||||||
@@ -185,6 +224,7 @@ test('pending images publish immediately, bytes share the durable cut, and indep
|
|||||||
engine.editor.dispatch({ type: 'element.data', target: { id: 'a', incarnation: 1 }, patch: { title: 'independent' } });
|
engine.editor.dispatch({ type: 'element.data', target: { id: 'a', incarnation: 1 }, patch: { title: 'independent' } });
|
||||||
await until(() => f.writes.length === 1);
|
await until(() => f.writes.length === 1);
|
||||||
assert.equal(f.uploads.length, 0);
|
assert.equal(f.uploads.length, 0);
|
||||||
|
// 检查实际交给 storage 的批次,而非只看当前内存状态,证明文件与图片内容同 cut 保存。
|
||||||
const fileBatch = f.batches.find(batch => batch.files?.length);
|
const fileBatch = f.batches.find(batch => batch.files?.length);
|
||||||
assert.equal(fileBatch.image.payload.document.elements.some(element => element.id === 'picture'), true);
|
assert.equal(fileBatch.image.payload.document.elements.some(element => element.id === 'picture'), true);
|
||||||
assert.equal(fileBatch.image.payload.runtime.resources.length, 1);
|
assert.equal(fileBatch.image.payload.runtime.resources.length, 1);
|
||||||
@@ -202,6 +242,7 @@ test('pending images publish immediately, bytes share the durable cut, and indep
|
|||||||
await engine.close();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 上传服务已成功但响应丢失,依赖图片文档仍等待;重开查询同一冻结 ID/body,恢复原资源后只提交一次图片文档。
|
||||||
test('lost upload acknowledgment recovers original byte identity and query receipt without recreating a node', async () => {
|
test('lost upload acknowledgment recovers original byte identity and query receipt without recreating a node', async () => {
|
||||||
const f = fixture(), upload = f.assets.upload;
|
const f = fixture(), upload = f.assets.upload;
|
||||||
f.assets.upload = async (...args) => { await upload(...args); return { outcome: 'unresolved' }; };
|
f.assets.upload = async (...args) => { await upload(...args); return { outcome: 'unresolved' }; };
|
||||||
@@ -209,9 +250,11 @@ test('lost upload acknowledgment recovers original byte identity and query recei
|
|||||||
const frozen = f.uploads[0]; assert.ok(frozen); assert.equal(f.writes.length, 0);
|
const frozen = f.uploads[0]; assert.ok(frozen); assert.equal(f.writes.length, 0);
|
||||||
const decoded = decodeRecovery(f.image(), registry);
|
const decoded = decodeRecovery(f.image(), registry);
|
||||||
assert.equal(decoded.runtime.resources.get(ref.id).id, ref.id); assert.equal(decoded.operations.size, 1); assert.equal(decoded.runtime.ledger.operations.size, 2);
|
assert.equal(decoded.runtime.resources.get(ref.id).id, ref.id); assert.equal(decoded.operations.size, 1); assert.equal(decoded.runtime.ledger.operations.size, 2);
|
||||||
|
// 破坏上传 operationId 的交叉引用应使恢复拒绝,避免把正确字节绑到另一上传工作。
|
||||||
const bad = structuredClone(f.image()); bad.payload.runtime.resources[0][1].operationId = 'not-the-upload';
|
const bad = structuredClone(f.image()); bad.payload.runtime.resources[0][1].operationId = 'not-the-upload';
|
||||||
assert.throws(() => decodeRecovery(bad, registry), /corrupt_recovery_image/);
|
assert.throws(() => decodeRecovery(bad, registry), /corrupt_recovery_image/);
|
||||||
await engine.close();
|
await engine.close();
|
||||||
|
// 重开时让时钟超过重试截止时间,要求先查原回执,不创建新上传请求或新节点。
|
||||||
const queries = [], query = f.assets.query;
|
const queries = [], query = f.assets.query;
|
||||||
f.assets.query = async request => { queries.push(request); return query(request); };
|
f.assets.query = async request => { queries.push(request); return query(request); };
|
||||||
const recovered = await f.open({ clock: { now: () => Date.now() + 100000, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: id => clearTimeout(id) } });
|
const recovered = await f.open({ clock: { now: () => Date.now() + 100000, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: id => clearTimeout(id) } });
|
||||||
@@ -223,6 +266,7 @@ test('lost upload acknowledgment recovers original byte identity and query recei
|
|||||||
await recovered.close();
|
await recovered.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 替换图片在本地立即可见,上传完成前服务端保留旧引用;undo/redo 和重开只切换引用,不重复上传或更换元素身份。
|
||||||
test('replacing an image keeps the old server reference during upload and undo/reopen/redo never uploads again',async()=>{
|
test('replacing an image keeps the old server reference during upload and undo/reopen/redo never uploads again',async()=>{
|
||||||
const f=fixture();let engine=await f.open();const original=addImage(engine);await engine.settle();const upload=f.assets.upload,gate=deferred(),entered=deferred();let replacement;
|
const f=fixture();let engine=await f.open();const original=addImage(engine);await engine.settle();const upload=f.assets.upload,gate=deferred(),entered=deferred();let replacement;
|
||||||
f.assets.upload=async(...args)=>{entered.resolve();await gate.promise;return upload(...args);};
|
f.assets.upload=async(...args)=>{entered.resolve();await gate.promise;return upload(...args);};
|
||||||
@@ -237,6 +281,7 @@ test('replacing an image keeps the old server reference during upload and undo/r
|
|||||||
}finally{gate.resolve();await engine.close();}
|
}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建重复 ID 被拒或回调为空时,不留下文件字节、资源记录或上传任务;失败事务不能泄漏已暂存工作。
|
||||||
test('rejected and empty staging do not retain file bytes or create upload work', async () => {
|
test('rejected and empty staging do not retain file bytes or create upload work', async () => {
|
||||||
const f = fixture(), engine = await f.open();
|
const f = fixture(), engine = await f.open();
|
||||||
const files = [{ name: 'image.png', bytes: new Blob(['image'], { type: 'image/png' }) }];
|
const files = [{ name: 'image.png', bytes: new Blob(['image'], { type: 'image/png' }) }];
|
||||||
@@ -248,6 +293,7 @@ test('rejected and empty staging do not retain file bytes or create upload work'
|
|||||||
await engine.close();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未知组件的资源 schema 使扫描不完整;明确注册的额外 owner 仍可单独保留已知资源。
|
||||||
test('unknown resource reference schemas prevent claiming complete garbage-collection coverage', async () => {
|
test('unknown resource reference schemas prevent claiming complete garbage-collection coverage', async () => {
|
||||||
const f = fixture(), engine = await f.open();
|
const f = fixture(), engine = await f.open();
|
||||||
const opaque = { ...node('future'), componentId: 'future-image', data: { privateResource: 'unknown' } };
|
const opaque = { ...node('future'), componentId: 'future-image', data: { privateResource: 'unknown' } };
|
||||||
@@ -259,11 +305,13 @@ test('unknown resource reference schemas prevent claiming complete garbage-colle
|
|||||||
await engine.close();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 上传回执声称成功但 assetId 错误时,保留原回执及 waiting 请求,不释放文档依赖,也不能把错误资源标为 stored。
|
||||||
test('invalid applied asset metadata retains the receipt and pending document without releasing the dependency', async () => {
|
test('invalid applied asset metadata retains the receipt and pending document without releasing the dependency', async () => {
|
||||||
const f = fixture(), upload = f.assets.upload;
|
const f = fixture(), upload = f.assets.upload;
|
||||||
f.assets.upload = async (...args) => { const result = await upload(...args); return { ...result, canonical: { ...result.canonical, id: 'different-resource' } }; };
|
f.assets.upload = async (...args) => { const result = await upload(...args); return { ...result, canonical: { ...result.canonical, id: 'different-resource' } }; };
|
||||||
const errors = [], engine = await f.open({ onError: error => errors.push(error) });
|
const errors = [], engine = await f.open({ onError: error => errors.push(error) });
|
||||||
const ref = addImage(engine); await engine.settle();
|
const ref = addImage(engine); await engine.settle();
|
||||||
|
// applied 是服务端执行事实,waiting 是本地尚未接纳:错误元数据不能把事实改回未执行,也不能解锁后续文档。
|
||||||
const request = f.uploads[0], stored = engine.getSnapshot().runtime.ledger.requests.get(request.id);
|
const request = f.uploads[0], stored = engine.getSnapshot().runtime.ledger.requests.get(request.id);
|
||||||
assert.equal(stored.outcome, 'applied'); assert.equal(stored.phase, 'waiting'); assert.equal(f.writes.length, 0);
|
assert.equal(stored.outcome, 'applied'); assert.equal(stored.phase, 'waiting'); assert.equal(f.writes.length, 0);
|
||||||
assert.equal(engine.getSnapshot().runtime.receipts.get(request.id).id, 'different-resource');
|
assert.equal(engine.getSnapshot().runtime.receipts.get(request.id).id, 'different-resource');
|
||||||
@@ -272,6 +320,7 @@ test('invalid applied asset metadata retains the receipt and pending document wi
|
|||||||
await engine.close();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 资源和 image 的首次持久化失败后,本地图片仍可见,但上传与文档均不发送;关闭也报错,修复存储再完成可靠保存。
|
||||||
test('a failed image durable cut keeps the visible element and sends neither bytes nor document', async () => {
|
test('a failed image durable cut keeps the visible element and sends neither bytes nor document', async () => {
|
||||||
const f = fixture(), errors = [], engine = await f.open({ onError: error => errors.push(error) });
|
const f = fixture(), errors = [], engine = await f.open({ onError: error => errors.push(error) });
|
||||||
await engine.persist();
|
await engine.persist();
|
||||||
@@ -282,12 +331,14 @@ test('a failed image durable cut keeps the visible element and sends neither byt
|
|||||||
assert.equal(f.uploads.length, 0); assert.equal(f.writes.length, 0); assert.equal(f.files.size, 0);
|
assert.equal(f.uploads.length, 0); assert.equal(f.writes.length, 0); assert.equal(f.files.size, 0);
|
||||||
assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id);
|
assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id);
|
||||||
await assert.rejects(() => engine.close(), /quota/);
|
await assert.rejects(() => engine.close(), /quota/);
|
||||||
|
// 关闭失败不丢弃待保存成果;恢复原端口后再次关闭完成持久化,随后检查文件和历史。
|
||||||
f.storage.commit = commit; await engine.close();
|
f.storage.commit = commit; await engine.close();
|
||||||
assert.equal(await f.files.get(ref.id).text(), 'image bytes');
|
assert.equal(await f.files.get(ref.id).text(), 'image bytes');
|
||||||
assert.equal(decodeRecovery(f.image(), registry).history.undo.length, 1);
|
assert.equal(decodeRecovery(f.image(), registry).history.undo.length, 1);
|
||||||
assert.ok(errors.length);
|
assert.ok(errors.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 服务端上传已成功、accept 暂时失败时,通过恢复会话接纳 stored 身份并释放依赖文档;history 和冻结字节不变,无二次上传。
|
||||||
test('known successful upload coordination recovers stored identity and releases the dependent image without uploading again',async()=>{
|
test('known successful upload coordination recovers stored identity and releases the dependent image without uploading again',async()=>{
|
||||||
const f=fixture(),accept=f.assets.accept;let fail=true;f.assets.accept=(...args)=>{if(fail)throw Error('codec_temporarily_unavailable');return accept(...args);};
|
const f=fixture(),accept=f.assets.accept;let fail=true;f.assets.accept=(...args)=>{if(fail)throw Error('codec_temporarily_unavailable');return accept(...args);};
|
||||||
const engine=await f.open(),resource=addImage(engine);await until(()=>f.uploads.length===1);await engine.settle();const request=f.uploads[0],history=engine.getSnapshot().history;
|
const engine=await f.open(),resource=addImage(engine);await until(()=>f.uploads.length===1);await engine.settle();const request=f.uploads[0],history=engine.getSnapshot().history;
|
||||||
@@ -298,21 +349,27 @@ test('known successful upload coordination recovers stored identity and releases
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 摘要 worker 故障保留原文件和明确 blocker,不伪装成缺少字节,也不忙循环;显式 retry 恢复同一资源及创建历史。
|
||||||
test('digest worker failure remains distinct from missing bytes and explicit retry resumes the original resource',async()=>{
|
test('digest worker failure remains distinct from missing bytes and explicit retry resumes the original resource',async()=>{
|
||||||
const {importSourceDigest}=await import('../../src/lib/canvas-runtime/import-integrity.ts'),f=fixture(),errors=[];let calls=0;const engine=await f.open({digests:{async digest(bytes,signal){if(++calls===1)throw Object.assign(Error('worker failed'),{code:'digest_worker_failed'});return importSourceDigest(bytes,signal);}},onError:error=>errors.push(error)});
|
const {importSourceDigest}=await import('../../src/lib/canvas-runtime/import-integrity.ts'),f=fixture(),errors=[];let calls=0;const engine=await f.open({digests:{async digest(bytes,signal){if(++calls===1)throw Object.assign(Error('worker failed'),{code:'digest_worker_failed'});return importSourceDigest(bytes,signal);}},onError:error=>errors.push(error)});
|
||||||
try{const ref=addImage(engine);await engine.settle();const resource=engine.getSnapshot().runtime.resources.get(ref.id);assert.deepEqual(engine.getSnapshot().runtime.ledger.operations.get(resource.operationId).blockers,['digest_worker_failed']);assert.equal(f.files.size,1);assert.equal(f.uploads.length,0);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(calls,1,'failed worker retried in a busy loop');await engine.retry();await engine.settle();assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(errors.length,1);}finally{await engine.close();}
|
try{const ref=addImage(engine);await engine.settle();const resource=engine.getSnapshot().runtime.resources.get(ref.id);assert.deepEqual(engine.getSnapshot().runtime.ledger.operations.get(resource.operationId).blockers,['digest_worker_failed']);assert.equal(f.files.size,1);assert.equal(f.uploads.length,0);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(calls,1,'failed worker retried in a busy loop');await engine.retry();await engine.settle();assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(errors.length,1);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 墙上时间可手动推进,重试定时器仍用真实 setTimeout;用于精确检查回收宽限期。
|
||||||
const resourceClock=()=>{let now=1000;return{advance:ms=>{now+=ms;},now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};};
|
const resourceClock=()=>{let now=1000;return{advance:ms=>{now+=ms;},now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};};
|
||||||
|
// 删除 picture 后编辑 a,配合 historyLimit=1 淘汰图片撤销记录,再等待 ACK,真正移除旧图片引用。
|
||||||
async function unreferenceImage(engine){assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'evict old image history'}}).status,'applied');await engine.settle();}
|
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,不重新上传。
|
||||||
test('local collection persists a soft marker, waits retention and atomically deletes bytes with a v3 recovery tombstone',async()=>{
|
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{
|
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);
|
await engine.settle();await unreferenceImage(engine);assert.equal(engine.getResourcePins().pins.has(ref.id),false);
|
||||||
const content=engine.getSnapshot().document,history=engine.getSnapshot().history;
|
const content=engine.getSnapshot().document,history=engine.getSnapshot().history;
|
||||||
|
// 首次扫描只标记无引用时间;后续扫描必须同时满足完整覆盖和宽限期,才能删除物理文件。
|
||||||
const soft=await engine.collectResources({retentionMs:100});assert.deepEqual(soft.markedIds,[ref.id]);assert.deepEqual(soft.purgedIds,[]);assert.ok(f.files.has(ref.id));assert.equal(f.image().formatVersion,3);decodeRecovery(f.image(),registry);
|
const soft=await engine.collectResources({retentionMs:100});assert.deepEqual(soft.markedIds,[ref.id]);assert.deepEqual(soft.purgedIds,[]);assert.ok(f.files.has(ref.id));assert.equal(f.image().formatVersion,3);decodeRecovery(f.image(),registry);
|
||||||
clock.advance(99);assert.deepEqual((await engine.collectResources({retentionMs:100})).purgedIds,[]);clock.advance(1);
|
clock.advance(99);assert.deepEqual((await engine.collectResources({retentionMs:100})).purgedIds,[]);clock.advance(1);
|
||||||
const collected=await engine.collectResources({retentionMs:100});assert.deepEqual(collected.purgedIds,[ref.id]);assert.equal(f.files.has(ref.id),false);assert.equal(engine.getSnapshot().document,content);assert.equal(engine.getSnapshot().history,history);
|
const collected=await engine.collectResources({retentionMs:100});assert.deepEqual(collected.purgedIds,[ref.id]);assert.equal(f.files.has(ref.id),false);assert.equal(engine.getSnapshot().document,content);assert.equal(engine.getSnapshot().history,history);
|
||||||
|
// 用保存过的 batch 关联 deleteFiles 与 purgedAt,确保 purge 墓碑持久化时,对应文件删除也已包含在该批次中。
|
||||||
const deletion=f.batches.find(batch=>batch.deleteFiles?.includes(ref.id));assert.equal(deletion.image.payload.runtime.resources.find(([id])=>id===ref.id)[1].retirement.purgedAt,1100);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);
|
const deletion=f.batches.find(batch=>batch.deleteFiles?.includes(ref.id));assert.equal(deletion.image.payload.runtime.resources.find(([id])=>id===ref.id)[1].retirement.purgedAt,1100);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);
|
||||||
const cut=decodeRecovery(f.image(),registry);assert.equal(cut.runtime.resources.get(ref.id).retirement.purgedAt,1100);
|
const cut=decodeRecovery(f.image(),registry);assert.equal(cut.runtime.resources.get(ref.id).retirement.purgedAt,1100);
|
||||||
for(const version of [1,2]){const bad=structuredClone(f.image());bad.formatVersion=version;assert.throws(()=>decodeRecovery(bad,registry),/unsupported_recovery_version/);}
|
for(const version of [1,2]){const bad=structuredClone(f.image());bad.formatVersion=version;assert.throws(()=>decodeRecovery(bad,registry),/unsupported_recovery_version/);}
|
||||||
@@ -320,6 +377,7 @@ test('local collection persists a soft marker, waits retention and atomically de
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 文档、历史、显式 pin 或未知覆盖均阻止删除;资源重新被引用时清除退休标记,之后再次无引用需重新计时。
|
||||||
test('document/history/ACK/explicit pins and unknown coverage prevent collection, and repinning resets the soft retention clock',async()=>{
|
test('document/history/ACK/explicit pins and unknown coverage prevent collection, and repinning resets the soft retention clock',async()=>{
|
||||||
const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);try{
|
const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);try{
|
||||||
await engine.settle();assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);
|
await engine.settle();assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);
|
||||||
@@ -332,6 +390,7 @@ test('document/history/ACK/explicit pins and unknown coverage prevent collection
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// display 在异步磁盘读取前就取得 pin 并清除退休状态;句柄重复 release 安全,最后释放后重新启动回收宽限期。
|
||||||
test('a display pins before its asynchronous read and resets retirement; releasing it starts a fresh retention window',async()=>{
|
test('a display pins before its asynchronous read and resets retirement; releasing it starts a fresh retention window',async()=>{
|
||||||
const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);const read=f.storage.readFile,gate=deferred(),entered=deferred();let blocked=false;try{
|
const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);const read=f.storage.readFile,gate=deferred(),entered=deferred();let blocked=false;try{
|
||||||
await engine.settle();await unreferenceImage(engine);await engine.collectResources({retentionMs:100});clock.advance(200);
|
await engine.settle();await unreferenceImage(engine);await engine.collectResources({retentionMs:100});clock.advance(200);
|
||||||
@@ -340,6 +399,7 @@ test('a display pins before its asynchronous read and resets retirement; releasi
|
|||||||
}finally{gate.resolve();await engine.close();}
|
}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 两个 reader 共用一次磁盘读取及 URL;取消其中一个不释放另一个的 pin,直到最后句柄 release 才撤销 URL 并允许回收。
|
||||||
test('shared display reads and URLs retain byte protection until the last independent reader releases', async () => {
|
test('shared display reads and URLs retain byte protection until the last independent reader releases', async () => {
|
||||||
const f = fixture(), engine = await f.open({ historyLimit: 1 }), ref = addImage(engine), gate = deferred(), entered = deferred();
|
const f = fixture(), engine = await f.open({ historyLimit: 1 }), ref = addImage(engine), gate = deferred(), entered = deferred();
|
||||||
const read = f.storage.readFile, createURL = URL.createObjectURL, revokeURL = URL.revokeObjectURL;
|
const read = f.storage.readFile, createURL = URL.createObjectURL, revokeURL = URL.revokeObjectURL;
|
||||||
@@ -348,18 +408,21 @@ test('shared display reads and URLs retain byte protection until the last indepe
|
|||||||
await engine.settle(); await unreferenceImage(engine);
|
await engine.settle(); await unreferenceImage(engine);
|
||||||
f.storage.readFile = async (...args) => { reads++; entered.resolve(); await gate.promise; return read(...args); };
|
f.storage.readFile = async (...args) => { reads++; entered.resolve(); await gate.promise; return read(...args); };
|
||||||
URL.createObjectURL = bytes => { created++; return createURL(bytes); }; URL.revokeObjectURL = url => { revoked++; return revokeURL(url); };
|
URL.createObjectURL = bytes => { created++; return createURL(bytes); }; URL.revokeObjectURL = url => { revoked++; return revokeURL(url); };
|
||||||
|
// 两个调用各自持有 reader 生命周期;共享底层读取不能让一个 AbortSignal 取消另一个调用。
|
||||||
const cancel = new AbortController(), first = engine.displayResource(ref.id, { signal: cancel.signal }), second = engine.displayResource(ref.id);
|
const cancel = new AbortController(), first = engine.displayResource(ref.id, { signal: cancel.signal }), second = engine.displayResource(ref.id);
|
||||||
await entered.promise; assert.equal(reads, 1);
|
await entered.promise; assert.equal(reads, 1);
|
||||||
const cancelled = assert.rejects(first, { code: 'display_aborted' }); cancel.abort(); await cancelled;
|
const cancelled = assert.rejects(first, { code: 'display_aborted' }); cancel.abort(); await cancelled;
|
||||||
assert.ok(engine.getResourcePins().pins.get(ref.id).has('display')); assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, []);
|
assert.ok(engine.getResourcePins().pins.get(ref.id).has('display')); assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, []);
|
||||||
gate.resolve(); const b = await second, c = await engine.displayResource(ref.id);
|
gate.resolve(); const b = await second, c = await engine.displayResource(ref.id);
|
||||||
assert.equal(b.url, c.url); assert.equal(created, 1); assert.equal(reads, 1);
|
assert.equal(b.url, c.url); assert.equal(created, 1); assert.equal(reads, 1);
|
||||||
|
// release 幂等且按句柄计数;另一个句柄仍在时不能撤销共享 URL 或移除字节保护。
|
||||||
b.release(); b.release(); assert.equal(revoked, 0); assert.ok(engine.getResourcePins().pins.get(ref.id).has('display'));
|
b.release(); b.release(); assert.equal(revoked, 0); assert.ok(engine.getResourcePins().pins.get(ref.id).has('display'));
|
||||||
c.release(); assert.equal(revoked, 1); assert.equal(engine.getResourcePins().pins.has(ref.id), false);
|
c.release(); assert.equal(revoked, 1); assert.equal(engine.getResourcePins().pins.has(ref.id), false);
|
||||||
assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, [ref.id]);
|
assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, [ref.id]);
|
||||||
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; }
|
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 关闭引擎立即拒绝挂起 display;底层文件读取后来完成也不能创建 URL 或复活显示工作。
|
||||||
test('project close releases a pending display immediately and never creates a URL from a late read', async () => {
|
test('project close releases a pending display immediately and never creates a URL from a late read', async () => {
|
||||||
const f = fixture(), engine = await f.open(), ref = addImage(engine), gate = deferred(), entered = deferred(), read = f.storage.readFile, createURL = URL.createObjectURL;
|
const f = fixture(), engine = await f.open(), ref = addImage(engine), gate = deferred(), entered = deferred(), read = f.storage.readFile, createURL = URL.createObjectURL;
|
||||||
let created = 0;
|
let created = 0;
|
||||||
@@ -371,6 +434,7 @@ test('project close releases a pending display immediately and never creates a U
|
|||||||
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 磁盘读取错误保留原错误码,失败 reader 释放显示 pin;恢复端口后新 reader 可重试而不再次上传。
|
||||||
test('display failures retain their cause and a fresh reader can retry without uploading again', async () => {
|
test('display failures retain their cause and a fresh reader can retry without uploading again', async () => {
|
||||||
const f = fixture(), engine = await f.open(), ref = addImage(engine), read = f.storage.readFile;
|
const f = fixture(), engine = await f.open(), ref = addImage(engine), read = f.storage.readFile;
|
||||||
try {
|
try {
|
||||||
@@ -381,6 +445,7 @@ test('display failures retain their cause and a fresh reader can retry without u
|
|||||||
} finally { await engine.close(); }
|
} finally { await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 账户身份失效同时挡住新 reader 和挂起读取的迟到结果,不创建 URL,所有 display pin 均释放。
|
||||||
test('identity retirement fences new readers and discards a pending local read before creating its URL', async () => {
|
test('identity retirement fences new readers and discards a pending local read before creating its URL', async () => {
|
||||||
const f = fixture(); let current = true;
|
const f = fixture(); let current = true;
|
||||||
const engine = await f.open({ isCurrentIdentity: () => current }), ref = addImage(engine), read = f.storage.readFile, gate = deferred(), entered = deferred();
|
const engine = await f.open({ isCurrentIdentity: () => current }), ref = addImage(engine), read = f.storage.readFile, gate = deferred(), entered = deferred();
|
||||||
@@ -393,6 +458,7 @@ test('identity retirement fences new readers and discards a pending local read b
|
|||||||
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 物理删除落盘期间预留目标资源,禁止再引用或 display 该资源;无关元素编辑保持可用。
|
||||||
test('only recollection of the reserved resource is fenced during deletion; unrelated editing stays available',async()=>{
|
test('only recollection of the reserved resource is fenced during deletion; unrelated editing stays available',async()=>{
|
||||||
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),gate=deferred(),entered=deferred(),commit=f.storage.commit;try{
|
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),gate=deferred(),entered=deferred(),commit=f.storage.commit;try{
|
||||||
await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(args[1].deleteFiles?.length){entered.resolve();await gate.promise;}return commit(...args);};
|
await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(args[1].deleteFiles?.length){entered.resolve();await gate.promise;}return commit(...args);};
|
||||||
@@ -402,6 +468,7 @@ test('only recollection of the reserved resource is fenced during deletion; unre
|
|||||||
}finally{gate.resolve();await engine.close();}
|
}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 删除落盘失败后保留字节及 reservation,旧持久化 image 没有 purge 墓碑;显式 persist 成功后才删除并释放,显示回退远端 URL。
|
||||||
test('failed deletion keeps bytes, pending recovery cut and reservation until an explicit persistence retry succeeds',async()=>{
|
test('failed deletion keeps bytes, pending recovery cut and reservation until an explicit persistence retry succeeds',async()=>{
|
||||||
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),commit=f.storage.commit;let fail=false;try{
|
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),commit=f.storage.commit;let fail=false;try{
|
||||||
await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk unavailable');return commit(...args);};fail=true;
|
await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk unavailable');return commit(...args);};fail=true;
|
||||||
@@ -410,14 +477,17 @@ test('failed deletion keeps bytes, pending recovery cut and reservation until an
|
|||||||
}finally{fail=false;await engine.close();}
|
}finally{fail=false;await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 订阅者在 purge 发布回调内同步调用 persist,也不能把 purge 墓碑先于 deleteFiles 单独保存。
|
||||||
test('a synchronous publication listener cannot persist a purge marker without its file deletion',async()=>{
|
test('a synchronous publication listener cannot persist a purge marker without its file deletion',async()=>{
|
||||||
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine);let unsubscribe;try{
|
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine);let unsubscribe;try{
|
||||||
await engine.settle();await unreferenceImage(engine);unsubscribe=engine.editor.subscribe(()=>{void engine.persist().catch(()=>{});});await engine.collectResources({retentionMs:0});assert.equal(f.files.has(ref.id),false);
|
await engine.settle();await unreferenceImage(engine);unsubscribe=engine.editor.subscribe(()=>{void engine.persist().catch(()=>{});});await engine.collectResources({retentionMs:0});assert.equal(f.files.has(ref.id),false);
|
||||||
|
// 即使发布监听器重入 persist,第一份携带 purgedAt 的持久化批次也必须已经包含物理删除。
|
||||||
const purged=f.batches.filter(batch=>batch.image.payload.runtime.resources.some(([id,resource])=>id===ref.id&&resource.retirement?.purgedAt!==undefined));assert.ok(purged.length);assert.ok(purged[0].deleteFiles.includes(ref.id),'first durable purge cut omitted physical deletion');
|
const purged=f.batches.filter(batch=>batch.image.payload.runtime.resources.some(([id,resource])=>id===ref.id&&resource.retirement?.purgedAt!==undefined));assert.ok(purged.length);assert.ok(purged[0].deleteFiles.includes(ref.id),'first durable purge cut omitted physical deletion');
|
||||||
}finally{unsubscribe?.();await engine.close();}
|
}finally{unsubscribe?.();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ACK 独有内容、活跃导入、不透明 run 输出及临时 copy pin 都参与引用保留;未知组件/字段 schema 使 complete=false,不能据可见性删字节。
|
||||||
test('retention covers ACK-only content, opaque job envelopes and historical field schemas independently of visibility',async()=>{
|
test('retention covers ACK-only content, opaque job envelopes and historical field schemas independently of visibility',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{
|
const f=fixture(),engine=await f.open();try{
|
||||||
const root=engine.getSnapshot(),empty=createDocument('project',[...root.document.canvases.values()],[]),resource={id:'kept',operationId:'upload',name:'x',mimeType:'image/png',byteLength:1},base={...root,document:empty,runtime:{...root.runtime,resources:new Map([['kept',resource]]),acknowledged:createDocument('project',[...empty.canvases.values()],[node('remote',{assetId:'kept'})])}};
|
const root=engine.getSnapshot(),empty=createDocument('project',[...root.document.canvases.values()],[]),resource={id:'kept',operationId:'upload',name:'x',mimeType:'image/png',byteLength:1},base={...root,document:empty,runtime:{...root.runtime,resources:new Map([['kept',resource]]),acknowledged:createDocument('project',[...empty.canvases.values()],[node('remote',{assetId:'kept'})])}};
|
||||||
@@ -433,20 +503,25 @@ test('retention covers ACK-only content, opaque job envelopes and historical fie
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 普通解码完成只发布元数据,不覆盖用户新几何;显式 fitImage 才按比例改尺寸并增加一个正常 undo 单元。
|
||||||
test('late image metadata never changes edited geometry; explicit fitting creates one normal undo unit',async()=>{
|
test('late image metadata never changes edited geometry; explicit fitting creates one normal undo unit',async()=>{
|
||||||
const f=fixture(),gate=deferred(),entered=deferred();const images={decode:async()=>{entered.resolve();await gate.promise;return{width:400,height:200};}},engine=await f.open({images}),ref=addImage(engine);try{await entered.promise;assert.equal(engine.editor.dispatch({type:'element.geometry',target:{id:'picture',incarnation:1},patch:{width:300,height:70}}).status,'applied');gate.resolve();await engine.settle();assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,{version:1,status:'ready',width:400,height:200,source:'decode'});assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.getSnapshot().history.undo.length,2);assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);assert.equal(engine.getSnapshot().history.undo.length,3);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.editor.history.redo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);await engine.settle();decodeRecovery(f.image(),registry);assert.equal(f.uploads.length,1);
|
const f=fixture(),gate=deferred(),entered=deferred();const images={decode:async()=>{entered.resolve();await gate.promise;return{width:400,height:200};}},engine=await f.open({images}),ref=addImage(engine);try{await entered.promise;assert.equal(engine.editor.dispatch({type:'element.geometry',target:{id:'picture',incarnation:1},patch:{width:300,height:70}}).status,'applied');gate.resolve();await engine.settle();assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,{version:1,status:'ready',width:400,height:200,source:'decode'});assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.getSnapshot().history.undo.length,2);assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);assert.equal(engine.getSnapshot().history.undo.length,3);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.editor.history.redo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);await engine.settle();decodeRecovery(f.image(),registry);assert.equal(f.uploads.length,1);
|
||||||
}finally{gate.resolve();await engine.close();}
|
}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 解码失败不阻塞资源上传;retry 只重新解码并保持资源、元素、上传及历史身份。
|
||||||
test('image decode failure does not block upload and retry keeps resource, element and upload identities',async()=>{
|
test('image decode failure does not block upload and retry keeps resource, element and upload identities',async()=>{
|
||||||
const f=fixture();let fail=true,calls=0;const engine=await f.open({images:{decode:async()=>{calls++;if(fail)throw Error('bad decoder');return{width:40,height:20};}}}),ref=addImage(engine);try{await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'failed');assert.equal(f.uploads.length,1);fail=false;await engine.retry();await engine.settle();assert.equal(calls,2);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{await engine.close();}
|
const f=fixture();let fail=true,calls=0;const engine=await f.open({images:{decode:async()=>{calls++;if(fail)throw Error('bad decoder');return{width:40,height:20};}}}),ref=addImage(engine);try{await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'failed');assert.equal(f.uploads.length,1);fail=false;await engine.retry();await engine.settle();assert.equal(calls,2);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 资源元数据可跨元素删除/恢复继续使用,但 fitImage 必须匹配当前 incarnation 与 assetId,旧身份或替代资源都拒绝。
|
||||||
test('decoder metadata can cross deletion/restoration, but fitting cannot target a stale incarnation or replaced resource',async()=>{
|
test('decoder metadata can cross deletion/restoration, but fitting cannot target a stale incarnation or replaced resource',async()=>{
|
||||||
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:20,height:10};}}}),ref=addImage(engine);try{await entered.promise;engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});engine.editor.history.undo();const restored=engine.getSnapshot().document.elements.get('picture');assert.equal(restored.incarnation,2);gate.resolve();await engine.settle();assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},'another-resource').code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},ref.id).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,50);}finally{gate.resolve();await engine.close();}
|
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:20,height:10};}}}),ref=addImage(engine);try{await entered.promise;engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});engine.editor.history.undo();const restored=engine.getSnapshot().document.elements.get('picture');assert.equal(restored.incarnation,2);gate.resolve();await engine.settle();assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},'another-resource').code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},ref.id).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,50);}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 恢复预览 hold 期间解码虽完成也不能发布新状态根;取消会话释放协调边界后才接纳 ready 元数据。
|
||||||
test('recovery holds defer image completion metadata until the coordinated root is released',async()=>{
|
test('recovery holds defer image completion metadata until the coordinated root is released',async()=>{
|
||||||
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:10,height:5};}}}),ref=addImage(engine);try{await entered.promise;const recovery=await engine.prepareRecovery(),revision=engine.getSnapshot().rootRevision;gate.resolve();await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'pending');await recovery.cancel();await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');}finally{gate.resolve();await engine.close();}
|
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:10,height:5};}}}),ref=addImage(engine);try{await entered.promise;const recovery=await engine.prepareRecovery(),revision=engine.getSnapshot().rootRevision;gate.resolve();await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'pending');await recovery.cancel();await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未来版本 image 元数据不触发重解码或静默迁移,原样持久化并阻止完整 GC 声明,仍允许无关编辑。
|
||||||
test('future image metadata remains opaque without blocking unrelated editing or being silently reinterpreted',async()=>{
|
test('future image metadata remains opaque without blocking unrelated editing or being silently reinterpreted',async()=>{
|
||||||
const f=fixture();let engine=await f.open();const ref=addImage(engine);try{await engine.settle();await engine.close();const future={version:2,status:'future',payload:{retain:true}};f.image().payload.runtime.resources.find(([id])=>id===ref.id)[1].image=future;let calls=0;engine=await f.open({images:{decode:async()=>{calls++;return{width:1,height:1};}}});await engine.settle();assert.equal(calls,0);assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,future);assert.equal(engine.getResourcePins().complete,false);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'can edit'}}).status,'applied');await engine.settle();assert.deepEqual(decodeRecovery(f.image(),registry).runtime.resources.get(ref.id).image,future);assert.equal(f.uploads.length,1);}finally{await engine.close();}
|
const f=fixture();let engine=await f.open();const ref=addImage(engine);try{await engine.settle();await engine.close();const future={version:2,status:'future',payload:{retain:true}};f.image().payload.runtime.resources.find(([id])=>id===ref.id)[1].image=future;let calls=0;engine=await f.open({images:{decode:async()=>{calls++;return{width:1,height:1};}}});await engine.settle();assert.equal(calls,0);assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,future);assert.equal(engine.getResourcePins().complete,false);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'can edit'}}).status,'applied');await engine.settle();assert.deepEqual(decodeRecovery(f.image(),registry).runtime.resources.get(ref.id).image,future);assert.equal(f.uploads.length,1);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 应用保留投影及固定版ReactFlow编译扩展测试。既检查业务数据不被标记污染,也截取实际依赖源码在VM中执行可见性/选择selector。
|
||||||
|
* VM以简化Hook/几何服务替代浏览器,只验证编译补丁与选择逻辑,不能代表真实DOM、布局和交互端到端验证。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {readFile} from 'node:fs/promises';
|
import {readFile} from 'node:fs/promises';
|
||||||
import {createRequire} from 'node:module';
|
import {createRequire} from 'node:module';
|
||||||
import {runInNewContext} from 'node:vm';
|
import {runInNewContext} from 'node:vm';
|
||||||
import {createRetainedProjection} from '../../src/components/canvas/retained-projection.ts';
|
import {createRetainedProjection} from '../../src/components/canvas/retained-projection.ts';
|
||||||
|
// 通过createRequire加载CommonJS loader,直接处理已安装包的两种ESM入口,避免测试只验证手写仿制字符串。
|
||||||
const require=createRequire(import.meta.url),transform=require('../../scripts/loaders/xyflow-retained.cjs');
|
const require=createRequire(import.meta.url),transform=require('../../scripts/loaders/xyflow-retained.cjs');
|
||||||
|
|
||||||
|
// 保留仅浅复制被点名的行,嵌套data/position共享且原对象未写标记;无保留时返回原数组,新源对象不复用旧副本。
|
||||||
test('retention keeps the complete native graph, preserves business data and releases flags after the owner ends',()=>{
|
test('retention keeps the complete native graph, preserves business data and releases flags after the owner ends',()=>{
|
||||||
const project=createRetainedProjection(),a=Object.freeze({id:'a',dragging:false,data:Object.freeze({text:'draft'}),position:{x:2000,y:0}}),b={id:'b'},edge={id:'edge',source:'a',target:'b'},nodes=[a,b],edges=[edge];
|
const project=createRetainedProjection(),a=Object.freeze({id:'a',dragging:false,data:Object.freeze({text:'draft'}),position:{x:2000,y:0}}),b={id:'b'},edge={id:'edge',source:'a',target:'b'},nodes=[a,b],edges=[edge];
|
||||||
const retained=project(nodes,edges,new Set(['a','edge']));
|
const retained=project(nodes,edges,new Set(['a','edge']));
|
||||||
@@ -16,15 +22,18 @@ test('retention keeps the complete native graph, preserves business data and rel
|
|||||||
const restored={...a,data:{text:'new incarnation'}},next=project([restored,b],edges,new Set(['a']));assert.notEqual(next.nodes[0],retained.nodes[0]);assert.equal(next.nodes[0].data,restored.data);
|
const restored={...a,data:{text:'new incarnation'}},next=project([restored,b],edges,new Set(['a']));assert.notEqual(next.nodes[0],retained.nodes[0]);assert.equal(next.nodes[0].data,restored.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 分别处理index.js/mjs,保留未hidden显式节点与边,关闭裁剪仍返完整图;源码锚点改动必须拒绝转换。
|
||||||
test('pinned xyflow compiler extension retains only explicit offscreen owners and keeps native visibility',async()=>{
|
test('pinned xyflow compiler extension retains only explicit offscreen owners and keeps native visibility',async()=>{
|
||||||
for(const file of ['index.js','index.mjs']){
|
for(const file of ['index.js','index.mjs']){
|
||||||
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
|
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
|
||||||
const start=code.indexOf('const selector$c = '),end=code.indexOf('\n};',start)+3;
|
const start=code.indexOf('const selector$c = '),end=code.indexOf('\n};',start)+3;
|
||||||
|
// 仅执行修改后的节点selector,getNodesInside固定返回原生可见节点,便于验证额外保留集合。
|
||||||
const select=runInNewContext(code.slice(start,end)+'; selector$c', {getNodesInside:()=>[{id:'visible'}]});
|
const select=runInNewContext(code.slice(start,end)+'; selector$c', {getNodesInside:()=>[{id:'visible'}]});
|
||||||
const nodes=[{id:'visible'},{id:'pinned',__endoleapRetain:true},{id:'other'},{id:'hidden',__endoleapRetain:true,hidden:true}],state={nodes,nodeLookup:new Map(nodes.map(node=>[node.id,node])),width:100,height:100,transform:[0,0,1]};
|
const nodes=[{id:'visible'},{id:'pinned',__endoleapRetain:true},{id:'other'},{id:'hidden',__endoleapRetain:true,hidden:true}],state={nodes,nodeLookup:new Map(nodes.map(node=>[node.id,node])),width:100,height:100,transform:[0,0,1]};
|
||||||
assert.deepEqual([...select(true)(state)],['visible','pinned']);assert.deepEqual([...select(false)(state)],nodes.map(node=>node.id));assert.equal(state.nodeLookup.size,4);
|
assert.deepEqual([...select(true)(state)],['visible','pinned']);assert.deepEqual([...select(false)(state)],nodes.map(node=>node.id));assert.equal(state.nodeLookup.size,4);
|
||||||
const edgeStart=code.indexOf('function useVisibleEdgeIds('),edgeEnd=code.indexOf('\n}',edgeStart)+2;
|
const edgeStart=code.indexOf('function useVisibleEdgeIds('),edgeEnd=code.indexOf('\n}',edgeStart)+2;
|
||||||
const edges=[{id:'near',source:'visible',target:'other'},{id:'editing',source:'pinned',target:'other',__endoleapRetain:true},{id:'offscreen',source:'other',target:'pinned'}];
|
const edges=[{id:'near',source:'visible',target:'other'},{id:'editing',source:'pinned',target:'other',__endoleapRetain:true},{id:'offscreen',source:'other',target:'pinned'}];
|
||||||
|
// 用简化useStore/useCallback执行边可见ID函数;原生可见near与显式保留editing都要在,offscreen不应在。
|
||||||
const result=runInNewContext(code.slice(edgeStart,edgeEnd)+'; useVisibleEdgeIds(true)',{useCallback:fn=>fn,useStore:fn=>fn({...state,edges}),shallow:()=>{},isEdgeVisible:({sourceNode})=>sourceNode.id==='visible'});
|
const result=runInNewContext(code.slice(edgeStart,edgeEnd)+'; useVisibleEdgeIds(true)',{useCallback:fn=>fn,useStore:fn=>fn({...state,edges}),shallow:()=>{},isEdgeVisible:({sourceNode})=>sourceNode.id==='visible'});
|
||||||
assert.deepEqual([...result],['near','editing']);
|
assert.deepEqual([...result],['near','editing']);
|
||||||
assert.throws(()=>transform(source.replace('const selector$c =','const changedSelector =').replace('getNodesInside(s.nodeLookup,','changedVisibility(s.nodeLookup,')),/Unsupported xyflow/);
|
assert.throws(()=>transform(source.replace('const selector$c =','const changedSelector =').replace('getNodesInside(s.nodeLookup,','changedVisibility(s.nodeLookup,')),/Unsupported xyflow/);
|
||||||
@@ -32,12 +41,14 @@ test('pinned xyflow compiler extension retains only explicit offscreen owners an
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 空监听数组不扫描也不运行effect,hook订阅或callback prop存在才扫描;返回userNode原引用而非内部包装。
|
||||||
test('native selection scanning stays dormant without listeners and still serves hooks and the callback prop',async()=>{
|
test('native selection scanning stays dormant without listeners and still serves hooks and the callback prop',async()=>{
|
||||||
for(const file of ['index.js','index.mjs']){
|
for(const file of ['index.js','index.mjs']){
|
||||||
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
|
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
|
||||||
const start=code.indexOf('const selector$l = '),end=code.indexOf('\n}',code.indexOf('function SelectionListener({',start))+2;
|
const start=code.indexOf('const selector$l = '),end=code.indexOf('\n}',code.indexOf('function SelectionListener({',start))+2;
|
||||||
let scans=0,effects=0;const callbacks=[],userNode={id:'selected',position:{x:0,y:0},data:{title:'original payload'},selected:true},node={...userNode,internals:{userNode}},edge={id:'edge',source:'selected',target:'other',selected:true};
|
let scans=0,effects=0;const callbacks=[],userNode={id:'selected',position:{x:0,y:0},data:{title:'original payload'},selected:true},node={...userNode,internals:{userNode}},edge={id:'edge',source:'selected',target:'other',selected:true};
|
||||||
const lookup=value=>({*[Symbol.iterator](){scans++;yield[value.id,value];}}),state={nodeLookup:lookup(node),edgeLookup:lookup(edge),onSelectionChangeHandlers:[]};
|
const lookup=value=>({*[Symbol.iterator](){scans++;yield[value.id,value];}}),state={nodeLookup:lookup(node),edgeLookup:lookup(edge),onSelectionChangeHandlers:[]};
|
||||||
|
// 把React Hook替为同步桩并计数扫描/副作用;jsx直接调用组件,仍使用依赖源码中的实际选择逻辑。
|
||||||
const render=runInNewContext(code.slice(start,end)+'; SelectionListener',{useStore:selector=>selector(state),useStoreApi:()=>({getState:()=>state}),useEffect:callback=>{effects++;callback();},jsx:(Component,props)=>Component(props),shallow:(a,b)=>JSON.stringify(a)===JSON.stringify(b)});
|
const render=runInNewContext(code.slice(start,end)+'; SelectionListener',{useStore:selector=>selector(state),useStoreApi:()=>({getState:()=>state}),useEffect:callback=>{effects++;callback();},jsx:(Component,props)=>Component(props),shallow:(a,b)=>JSON.stringify(a)===JSON.stringify(b)});
|
||||||
assert.equal(render({}),null);assert.equal(scans,0);assert.equal(effects,0);
|
assert.equal(render({}),null);assert.equal(scans,0);assert.equal(effects,0);
|
||||||
state.onSelectionChangeHandlers=[value=>callbacks.push(value)];render({});assert.equal(scans,2);assert.deepEqual([...callbacks[0].nodes].map(node=>node.id),['selected']);assert.deepEqual([...callbacks[0].edges].map(edge=>edge.id),['edge']);assert.equal(callbacks[0].nodes[0],userNode);assert.equal(callbacks[0].edges[0],edge);
|
state.onSelectionChangeHandlers=[value=>callbacks.push(value)];render({});assert.equal(scans,2);assert.deepEqual([...callbacks[0].nodes].map(node=>node.id),['selected']);assert.deepEqual([...callbacks[0].edges].map(edge=>edge.id),['edge']);assert.equal(callbacks[0].nodes[0],userNode);assert.equal(callbacks[0].edges[0],edge);
|
||||||
|
|||||||
@@ -1,50 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 运行事实按资源/操作/作用域计数订阅的测试。用Tracked表记录get/values工作量,对照朴素过滤定义验证索引语义。
|
||||||
|
* 只读document更新不该重扫ledger/resource;请求结果、资源绑定与错误恢复则必须准确通知相关通道。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createRuntimeSources} from '../../src/lib/canvas-bindings/runtime-sources.ts';
|
import {createRuntimeSources} from '../../src/lib/canvas-bindings/runtime-sources.ts';
|
||||||
|
/** Map探针:get记录点查并可注入异常,values记录全表扫描,reset仅清计数;数据仍按普通Map保存。 */
|
||||||
class Tracked extends Map{gets=0;scans=0;broken=false;get(id){this.gets++;if(this.broken)throw Error('table unavailable');return super.get(id);}values(){this.scans++;return super.values();}reset(){this.gets=0;this.scans=0;}}
|
class Tracked extends Map{gets=0;scans=0;broken=false;get(id){this.gets++;if(this.broken)throw Error('table unavailable');return super.get(id);}values(){this.scans++;return super.values();}reset(){this.gets=0;this.scans=0;}}
|
||||||
|
/** 最小操作事实工厂,canvasIds/phase/blockers由用例指定,以覆盖多画布、仅项目及摘要等待情况。 */
|
||||||
const operation=(id,canvasIds=['a'],phase='pending',blockers=[])=>({intent:{id,canvasIds},phase,blockers});
|
const operation=(id,canvasIds=['a'],phase='pending',blockers=[])=>({intent:{id,canvasIds},phase,blockers});
|
||||||
|
/** 构造可替换root及带计数的ledger/resources/assetBindings;broken制造上游订阅失败,emit不额外合成变化清单。 */
|
||||||
function fixture(count=3){let root={document:{},runtime:{ledger:{operations:new Tracked(Array.from({length:count},(_,index)=>[String(index),operation(String(index))])),requests:new Tracked()},resources:new Tracked(Array.from({length:count},(_,index)=>[String(index),{id:String(index),name:'image',operationId:'upload-'+index}])),assetBindings:new Tracked()}},broken=false;const listeners=new Set();const source={getSnapshot:()=>root,subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get:()=>root,get size(){return listeners.size;},broken(value){broken=value;},emit(next){root=next;for(const fn of [...listeners])fn();}};}
|
function fixture(count=3){let root={document:{},runtime:{ledger:{operations:new Tracked(Array.from({length:count},(_,index)=>[String(index),operation(String(index))])),requests:new Tracked()},resources:new Tracked(Array.from({length:count},(_,index)=>[String(index),{id:String(index),name:'image',operationId:'upload-'+index}])),assetBindings:new Tracked()}},broken=false;const listeners=new Set();const source={getSnapshot:()=>root,subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get:()=>root,get size(){return listeners.size;},broken(value){broken=value;},emit(next){root=next;for(const fn of [...listeners])fn();}};}
|
||||||
|
|
||||||
|
// 2000通道共用上游,document-only不查三张表;改资源300仅通知asset300,不读取operations/requests。
|
||||||
test('2000 asset/operation subscribers share one listener and unrelated document cuts read no tables',()=>{
|
test('2000 asset/operation subscribers share one listener and unrelated document cuts read no tables',()=>{
|
||||||
const f=fixture(1000),sources=createRuntimeSources(f.source),seen=[],stops=[];for(let index=0;index<1000;index++){const id=String(index);stops.push(sources.asset(id).subscribe(()=>seen.push('asset'+id)),sources.operation(id).subscribe(()=>seen.push('op'+id)));}assert.equal(f.size,1);const {resources,ledger}=f.get().runtime;resources.reset();ledger.operations.reset();ledger.requests.reset();f.emit({...f.get(),document:{edited:true}});assert.equal(resources.gets+ledger.operations.gets+ledger.requests.gets,0);assert.deepEqual(seen,[]);
|
const f=fixture(1000),sources=createRuntimeSources(f.source),seen=[],stops=[];for(let index=0;index<1000;index++){const id=String(index);stops.push(sources.asset(id).subscribe(()=>seen.push('asset'+id)),sources.operation(id).subscribe(()=>seen.push('op'+id)));}assert.equal(f.size,1);const {resources,ledger}=f.get().runtime;resources.reset();ledger.operations.reset();ledger.requests.reset();f.emit({...f.get(),document:{edited:true}});assert.equal(resources.gets+ledger.operations.gets+ledger.requests.gets,0);assert.deepEqual(seen,[]);
|
||||||
const next=new Tracked(resources);next.set('300',{...resources.get('300'),name:'new image'});f.emit({...f.get(),runtime:{...f.get().runtime,resources:next}});assert.deepEqual(seen,['asset300']);assert.equal(ledger.operations.gets+ledger.requests.gets,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
const next=new Tracked(resources);next.set('300',{...resources.get('300'),name:'new image'});f.emit({...f.get(),runtime:{...f.get().runtime,resources:next}});assert.deepEqual(seen,['asset300']);assert.equal(ledger.operations.gets+ledger.requests.gets,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 只改关联request结果/错误也应通知operation0,未关联operation1保持原对象引用。
|
||||||
test('request-only updates notify the linked operation while unrelated operation views stay stable',()=>{
|
test('request-only updates notify the linked operation while unrelated operation views stay stable',()=>{
|
||||||
const f=fixture(),ops=new Tracked(f.get().runtime.ledger.operations),requests=new Tracked([['request',{id:'request',phase:'waiting',outcome:'accepted'}]]);ops.set('0',{...ops.get('0'),requestId:'request'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests}}});const sources=createRuntimeSources(f.source),a=sources.operation('0'),b=sources.operation('1'),seen=[],before=b.getSnapshot(),stops=[a.subscribe(()=>seen.push('0')),b.subscribe(()=>seen.push('1'))];const changed=new Tracked(requests);changed.set('request',{id:'request',phase:'waiting',outcome:'unresolved',error:'transport'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests:changed}}});assert.deepEqual(seen,['0']);assert.equal(a.getSnapshot().request.error,'transport');assert.strictEqual(b.getSnapshot(),before);for(const stop of stops)stop();
|
const f=fixture(),ops=new Tracked(f.get().runtime.ledger.operations),requests=new Tracked([['request',{id:'request',phase:'waiting',outcome:'accepted'}]]);ops.set('0',{...ops.get('0'),requestId:'request'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests}}});const sources=createRuntimeSources(f.source),a=sources.operation('0'),b=sources.operation('1'),seen=[],before=b.getSnapshot(),stops=[a.subscribe(()=>seen.push('0')),b.subscribe(()=>seen.push('1'))];const changed=new Tracked(requests);changed.set('request',{id:'request',phase:'waiting',outcome:'unresolved',error:'transport'});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests:changed}}});assert.deepEqual(seen,['0']);assert.equal(a.getSnapshot().request.error,'transport');assert.strictEqual(b.getSnapshot(),before);for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 较早监听先调用getSnapshot不能吞掉后续通知;新增shared绑定后订阅者仍收到一次更新。
|
||||||
test('early snapshot reads cannot consume a binding notification before other observers receive it',()=>{
|
test('early snapshot reads cannot consume a binding notification before other observers receive it',()=>{
|
||||||
const f=fixture();let channel;const earlier=f.source.subscribe(()=>channel?.getSnapshot()),sources=createRuntimeSources(f.source);channel=sources.asset('shared');let seen=0;const stop=channel.subscribe(()=>seen++),binding={authority:'scope',digest:'a'.repeat(64),asset:{id:'shared'}};f.emit({...f.get(),runtime:{...f.get().runtime,assetBindings:new Tracked([['shared',binding]])}});assert.equal(seen,1);assert.equal(channel.getSnapshot().binding.digest,binding.digest);stop();earlier();assert.equal(f.size,0);
|
const f=fixture();let channel;const earlier=f.source.subscribe(()=>channel?.getSnapshot()),sources=createRuntimeSources(f.source);channel=sources.asset('shared');let seen=0;const stop=channel.subscribe(()=>seen++),binding={authority:'scope',digest:'a'.repeat(64),asset:{id:'shared'}};f.emit({...f.get(),runtime:{...f.get().runtime,assetBindings:new Tracked([['shared',binding]])}});assert.equal(seen,1);assert.equal(channel.getSnapshot().binding.digest,binding.digest);stop();earlier();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 多canvas请求按作用域只数一次,waiting_digest不当普通blocked;1000个空scope只需一次表扫描,loading改变正确重算。
|
||||||
test('sync counts index shared multi-canvas requests once and handles project-only operations',()=>{
|
test('sync counts index shared multi-canvas requests once and handles project-only operations',()=>{
|
||||||
const f=fixture(0),ops=new Tracked([['one',operation('one',['a','b','a'])],['two',operation('two',['b'],'blocked',['permission'])],['digest',operation('digest',['a'],'blocked',['waiting_digest'])],['done',operation('done',['a'],'resolved')],['project',operation('project',[])]]),requests=new Tracked([['r1',{phase:'waiting',outcome:'unresolved',error:'transport',operationIds:['one','two']}],['r2',{phase:'waiting',outcome:'unresolved',error:'old',operationIds:['done']}],['r3',{phase:'waiting',outcome:'unresolved',operationIds:['project']}],['r4',{phase:'waiting',outcome:'unresolved',error:'retry',retryAt:100,operationIds:['digest']}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests},loading:{version:6}}});const sources=createRuntimeSources(f.source);
|
const f=fixture(0),ops=new Tracked([['one',operation('one',['a','b','a'])],['two',operation('two',['b'],'blocked',['permission'])],['digest',operation('digest',['a'],'blocked',['waiting_digest'])],['done',operation('done',['a'],'resolved')],['project',operation('project',[])]]),requests=new Tracked([['r1',{phase:'waiting',outcome:'unresolved',error:'transport',operationIds:['one','two']}],['r2',{phase:'waiting',outcome:'unresolved',error:'old',operationIds:['done']}],['r3',{phase:'waiting',outcome:'unresolved',operationIds:['project']}],['r4',{phase:'waiting',outcome:'unresolved',error:'retry',retryAt:100,operationIds:['digest']}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations:ops,requests},loading:{version:6}}});const sources=createRuntimeSources(f.source);
|
||||||
assert.deepEqual(sources.counts().getSnapshot(),{pending:4,blocked:2,unknown:3,loading:true});assert.deepEqual(sources.counts('a').getSnapshot(),{pending:2,blocked:1,unknown:2,loading:true});assert.deepEqual(sources.counts('b').getSnapshot(),{pending:2,blocked:2,unknown:1,loading:true});for(let index=0;index<1000;index++)assert.deepEqual(sources.counts('missing-'+index).getSnapshot(),{pending:0,blocked:0,unknown:0,loading:true});assert.equal(ops.scans,1);assert.equal(requests.scans,1);
|
assert.deepEqual(sources.counts().getSnapshot(),{pending:4,blocked:2,unknown:3,loading:true});assert.deepEqual(sources.counts('a').getSnapshot(),{pending:2,blocked:1,unknown:2,loading:true});assert.deepEqual(sources.counts('b').getSnapshot(),{pending:2,blocked:2,unknown:1,loading:true});for(let index=0;index<1000;index++)assert.deepEqual(sources.counts('missing-'+index).getSnapshot(),{pending:0,blocked:0,unknown:0,loading:true});assert.equal(ops.scans,1);assert.equal(requests.scans,1);
|
||||||
const channel=sources.counts('a'),stop=channel.subscribe(()=>{});ops.reset();requests.reset();f.emit({...f.get(),document:{unrelated:true}});channel.getSnapshot();assert.equal(ops.scans+requests.scans,0);f.emit({...f.get(),runtime:{...f.get().runtime,loading:undefined}});assert.equal(channel.getSnapshot().loading,false);assert.equal(ops.scans,1);assert.equal(requests.scans,1);stop();
|
const channel=sources.counts('a'),stop=channel.subscribe(()=>{});ops.reset();requests.reset();f.emit({...f.get(),document:{unrelated:true}});channel.getSnapshot();assert.equal(ops.scans+requests.scans,0);f.emit({...f.get(),runtime:{...f.get().runtime,loading:undefined}});assert.equal(channel.getSnapshot().loading,false);assert.equal(ops.scans,1);assert.equal(requests.scans,1);stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 资源深相等克隆不通知且复用视图;退订后资源删除,直接读取仍能见record缺失。
|
||||||
test('cloned equal tables preserve view identity and unsubscribed channels still read current facts',()=>{
|
test('cloned equal tables preserve view identity and unsubscribed channels still read current facts',()=>{
|
||||||
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0'),before=channel.getSnapshot();let calls=0;const stop=channel.subscribe(()=>calls++);f.emit({...f.get(),runtime:{...f.get().runtime,resources:new Tracked([...f.get().runtime.resources].map(([id,row])=>[id,structuredClone(row)]))}});assert.equal(calls,0);assert.strictEqual(channel.getSnapshot(),before);stop();const resources=new Tracked(f.get().runtime.resources);resources.delete('0');f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(channel.getSnapshot().record,undefined);assert.equal(f.size,0);
|
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0'),before=channel.getSnapshot();let calls=0;const stop=channel.subscribe(()=>calls++);f.emit({...f.get(),runtime:{...f.get().runtime,resources:new Tracked([...f.get().runtime.resources].map(([id,row])=>[id,structuredClone(row)]))}});assert.equal(calls,0);assert.strictEqual(channel.getSnapshot(),before);stop();const resources=new Tracked(f.get().runtime.resources);resources.delete('0');f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(channel.getSnapshot().record,undefined);assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 表get抛错后读者收到异常,解除broken且无关document发布也应重试并通知恢复;stop幂等,上游最终清零。
|
||||||
test('lookup failures retry without poisoning cached facts, and failed subscription retains no live group',()=>{
|
test('lookup failures retry without poisoning cached facts, and failed subscription retains no live group',()=>{
|
||||||
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot(),resources=new Tracked(f.get().runtime.resources);resources.broken=true;f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/table unavailable/);resources.broken=false;f.emit({...f.get(),document:{metadata:true}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();stop();assert.equal(f.size,0);
|
const f=fixture(),sources=createRuntimeSources(f.source),channel=sources.asset('0');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;const stop=channel.subscribe(()=>calls++),before=channel.getSnapshot(),resources=new Tracked(f.get().runtime.resources);resources.broken=true;f.emit({...f.get(),runtime:{...f.get().runtime,resources}});assert.equal(calls,1);assert.throws(channel.getSnapshot,/table unavailable/);resources.broken=false;f.emit({...f.get(),document:{metadata:true}});assert.equal(calls,2);assert.strictEqual(channel.getSnapshot(),before);stop();stop();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 资源观察者抛错不饿死操作观察者;更早回调先清计数通道时迟到上游回调也不调用已退订观察者。
|
||||||
test('observer failures do not starve another collection and late captured callbacks after cleanup are safe',()=>{
|
test('observer failures do not starve another collection and late captured callbacks after cleanup are safe',()=>{
|
||||||
const f=fixture(),sources=createRuntimeSources(f.source);let other=0;const a=sources.asset('0').subscribe(()=>{throw Error('observer failed');}),b=sources.operation('0').subscribe(()=>other++),resources=new Tracked(f.get().runtime.resources),ops=new Tracked(f.get().runtime.ledger.operations);resources.set('0',{id:'0',operationId:'upload',name:'changed'});ops.set('0',operation('0',['a'],'blocked',['permission']));assert.throws(()=>f.emit({...f.get(),runtime:{...f.get().runtime,resources,ledger:{...f.get().runtime.ledger,operations:ops}}}),/observer failed/);assert.equal(other,1);a();b();assert.equal(f.size,0);
|
const f=fixture(),sources=createRuntimeSources(f.source);let other=0;const a=sources.asset('0').subscribe(()=>{throw Error('observer failed');}),b=sources.operation('0').subscribe(()=>other++),resources=new Tracked(f.get().runtime.resources),ops=new Tracked(f.get().runtime.ledger.operations);resources.set('0',{id:'0',operationId:'upload',name:'changed'});ops.set('0',operation('0',['a'],'blocked',['permission']));assert.throws(()=>f.emit({...f.get(),runtime:{...f.get().runtime,resources,ledger:{...f.get().runtime.ledger,operations:ops}}}),/observer failed/);assert.equal(other,1);a();b();assert.equal(f.size,0);
|
||||||
let stop;const earlier=f.source.subscribe(()=>stop());stop=sources.counts().subscribe(()=>{throw Error('detached observer');});assert.doesNotThrow(()=>f.emit({...f.get(),runtime:{...f.get().runtime,loading:{}}}));earlier();assert.equal(f.size,0);
|
let stop;const earlier=f.source.subscribe(()=>stop());stop=sources.counts().subscribe(()=>{throw Error('detached observer');});assert.doesNotThrow(()=>f.emit({...f.get(),runtime:{...f.get().runtime,loading:{}}}));earlier();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 关联请求由accepted变unresolved改变unknown/blocked,指向缺失操作的无关请求不影响scope a的计数引用。
|
||||||
test('request status changes update scoped sync counts and unrelated requests preserve cached count identity',()=>{
|
test('request status changes update scoped sync counts and unrelated requests preserve cached count identity',()=>{
|
||||||
const f=fixture(1),request={phase:'waiting',outcome:'accepted',operationIds:['0']};f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:new Tracked([['r',request]])}}});const sources=createRuntimeSources(f.source),channel=sources.counts('a');let calls=0;const stop=channel.subscribe(()=>calls++);assert.deepEqual(channel.getSnapshot(),{pending:1,blocked:0,unknown:0,loading:false});const requests=new Tracked([['r',{...request,outcome:'unresolved',error:'transport'}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests}}});assert.equal(calls,1);const counted=channel.getSnapshot();assert.deepEqual(counted,{pending:1,blocked:1,unknown:1,loading:false});const unrelated=new Tracked(requests);unrelated.set('other',{phase:'waiting',outcome:'unresolved',error:'other',operationIds:['missing']});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:unrelated}}});assert.equal(calls,1);assert.strictEqual(sources.counts('a').getSnapshot(),counted);stop();
|
const f=fixture(1),request={phase:'waiting',outcome:'accepted',operationIds:['0']};f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:new Tracked([['r',request]])}}});const sources=createRuntimeSources(f.source),channel=sources.counts('a');let calls=0;const stop=channel.subscribe(()=>calls++);assert.deepEqual(channel.getSnapshot(),{pending:1,blocked:0,unknown:0,loading:false});const requests=new Tracked([['r',{...request,outcome:'unresolved',error:'transport'}]]);f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests}}});assert.equal(calls,1);const counted=channel.getSnapshot();assert.deepEqual(counted,{pending:1,blocked:1,unknown:1,loading:false});const unrelated=new Tracked(requests);unrelated.set('other',{phase:'waiting',outcome:'unresolved',error:'other',operationIds:['missing']});f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{...f.get().runtime.ledger,requests:unrelated}}});assert.equal(calls,1);assert.strictEqual(sources.counts('a').getSnapshot(),counted);stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 固定种子的40组混合事实,遍历项目/a/b/c/missing作用域,与朴素筛选逐项深比较,保证优化不改原计数定义。
|
||||||
test('indexed counts preserve the previous filter definition over mixed request and operation states',()=>{
|
test('indexed counts preserve the previous filter definition over mixed request and operation states',()=>{
|
||||||
const f=fixture(0),sources=createRuntimeSources(f.source);let seed=7;const random=n=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed%n;};
|
const f=fixture(0),sources=createRuntimeSources(f.source);let seed=7;const random=n=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed%n;};
|
||||||
for(let sample=0;sample<40;sample++){
|
for(let sample=0;sample<40;sample++){
|
||||||
const operations=new Tracked(Array.from({length:24},(_,index)=>{const id='op'+index,canvases=Array.from({length:random(4)},()=>['a','b','c'][random(3)]);return[id,operation(id,canvases,['pending','blocked','frozen','resolved'][random(4)],random(2)?['waiting_digest']:['permission'])];})),requests=new Tracked(Array.from({length:12},(_,index)=>['r'+index,{phase:['prepared','dispatch-marked','waiting','resolved'][random(4)],outcome:[undefined,'unresolved','accepted','applied'][random(4)],error:random(2)?'transport':undefined,retryAt:random(2)?100:undefined,operationIds:Array.from({length:random(5)},()=>'op'+random(26))}]));f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations,requests},loading:sample%2?{}:undefined}});
|
const operations=new Tracked(Array.from({length:24},(_,index)=>{const id='op'+index,canvases=Array.from({length:random(4)},()=>['a','b','c'][random(3)]);return[id,operation(id,canvases,['pending','blocked','frozen','resolved'][random(4)],random(2)?['waiting_digest']:['permission'])];})),requests=new Tracked(Array.from({length:12},(_,index)=>['r'+index,{phase:['prepared','dispatch-marked','waiting','resolved'][random(4)],outcome:[undefined,'unresolved','accepted','applied'][random(4)],error:random(2)?'transport':undefined,retryAt:random(2)?100:undefined,operationIds:Array.from({length:random(5)},()=>'op'+random(26))}]));f.emit({...f.get(),runtime:{...f.get().runtime,ledger:{operations,requests},loading:sample%2?{}:undefined}});
|
||||||
for(const scope of [undefined,'a','b','c','missing']){
|
for(const scope of [undefined,'a','b','c','missing']){
|
||||||
|
// 朴素实现作为独立对照:先筛scope内未解决操作,再按逻辑ID关联请求,分别计算pending/blocked/unknown。
|
||||||
const selected=[...operations.values()].filter(row=>row.phase!=='resolved'&&(scope===undefined||row.intent.canvasIds.includes(scope))),ids=new Set(selected.map(row=>row.intent.id)),visible=[...requests.values()].filter(row=>row.phase!=='resolved'&&row.operationIds.some(id=>ids.has(id))),expected={pending:selected.length,blocked:selected.filter(row=>row.phase==='blocked'&&row.blockers.some(reason=>reason!=='waiting_digest')).length+visible.filter(row=>row.error&&row.retryAt===undefined).length,unknown:visible.filter(row=>row.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(row.phase)).length,loading:!!f.get().runtime.loading};assert.deepEqual(sources.counts(scope).getSnapshot(),expected,`sample ${sample}, scope ${scope}`);
|
const selected=[...operations.values()].filter(row=>row.phase!=='resolved'&&(scope===undefined||row.intent.canvasIds.includes(scope))),ids=new Set(selected.map(row=>row.intent.id)),visible=[...requests.values()].filter(row=>row.phase!=='resolved'&&row.operationIds.some(id=>ids.has(id))),expected={pending:selected.length,blocked:selected.filter(row=>row.phase==='blocked'&&row.blockers.some(reason=>reason!=='waiting_digest')).length+visible.filter(row=>row.error&&row.retryAt===undefined).length,unknown:visible.filter(row=>row.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(row.phase)).length,loading:!!f.get().runtime.loading};assert.deepEqual(sources.counts(scope).getSnapshot(),expected,`sample ${sample}, scope ${scope}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 增量场景投影的缓存与发布回归。renderer解析计数观察局部工作量,并以projectCanvas完整遍历作为结果对照。
|
||||||
|
* 会话捕获、文档hint连续性、scope/renderer身份及未知组件规则都必须保留,性能优化不能隐藏漏帧或改变端点。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {builtinRendererCatalog} from '../../src/lib/canvas-bindings/renderers.ts';
|
import {builtinRendererCatalog} from '../../src/lib/canvas-bindings/renderers.ts';
|
||||||
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
||||||
import {createSceneProjection} from '../../src/lib/canvas-bindings/scene-projection.ts';
|
import {createSceneProjection} from '../../src/lib/canvas-bindings/scene-projection.ts';
|
||||||
|
/** 默认root文本节点工厂,可换owner/component构造入口、后代和未知类型。 */
|
||||||
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||||
|
/** root含F/B,child含A,edge从A连B;两view共享sessions,catalog.resolve累计计数,project允许传旧会话/替换scope。 */
|
||||||
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A','child'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),scene=createSceneProjection(),owner={};sessions.attach('v');sessions.attach('w');let reads=0;const catalog={...builtinRendererCatalog,resolve(record){reads++;return builtinRendererCatalog.resolve(record);}};return{editor,sessions,scene,owner,registry,catalog,get reads(){return reads;},project(snapshot=sessions.getSnapshot(),document=editor.getSnapshot().document,scope=owner,renderers=catalog){return scene.project(scope,document,snapshot.views.get('v'),snapshot,registry,renderers,editor.getQueries(document));}};}
|
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A','child'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),scene=createSceneProjection(),owner={};sessions.attach('v');sessions.attach('w');let reads=0;const catalog={...builtinRendererCatalog,resolve(record){reads++;return builtinRendererCatalog.resolve(record);}};return{editor,sessions,scene,owner,registry,catalog,get reads(){return reads;},project(snapshot=sessions.getSnapshot(),document=editor.getSnapshot().document,scope=owner,renderers=catalog){return scene.project(scope,document,snapshot.views.get('v'),snapshot,registry,renderers,editor.getQueries(document));}};}
|
||||||
|
// 不影响当前图形内容的session变化复用整场景;本view选择改变selected并重新投影。
|
||||||
test('viewport, tool, modes and other-view selection do no scene traversal; this-view selection does',()=>{const f=fixture(),first=f.project(),reads=f.reads;assert.ok(reads>0);f.sessions.viewport('v',{x:40,y:20,zoom:2});f.sessions.tool('v','pan');f.sessions.mode('v','B','editing');f.sessions.select('w',['A']);assert.strictEqual(f.project(),first);assert.equal(f.reads,reads);f.sessions.select('v',['B']);const selected=f.project();assert.equal(selected.nodes.find(n=>n.id==='B').selected,true);assert.notStrictEqual(selected,first);assert.ok(f.reads>reads);});
|
test('viewport, tool, modes and other-view selection do no scene traversal; this-view selection does',()=>{const f=fixture(),first=f.project(),reads=f.reads;assert.ok(reads>0);f.sessions.viewport('v',{x:40,y:20,zoom:2});f.sessions.tool('v','pan');f.sessions.mode('v','B','editing');f.sessions.select('w',['A']);assert.strictEqual(f.project(),first);assert.equal(f.reads,reads);f.sessions.select('v',['B']);const selected=f.project();assert.equal(selected.nodes.find(n=>n.id==='B').selected,true);assert.notStrictEqual(selected,first);assert.ok(f.reads>reads);});
|
||||||
|
// 捕获w手势x=60后再改120,传旧snapshot仍显示60,live显示120且后代跟随;取消回原位置。
|
||||||
test('captured geometry previews remain coherent even after live update, and are shared across views',()=>{const f=fixture(),first=f.project();f.sessions.begin('w',[{id:'F',incarnation:1}]);assert.strictEqual(f.project(),first);f.sessions.update('w',{id:'F',incarnation:1},{x:60});const captured=f.sessions.getSnapshot();f.sessions.update('w',{id:'F',incarnation:1},{x:120});const old=f.project(captured),live=f.project();assert.equal(old.nodes.find(n=>n.id==='F').position.x,60);assert.equal(live.nodes.find(n=>n.id==='F').position.x,120);assert.equal(live.nodes.find(n=>n.id==='A').position.x-old.nodes.find(n=>n.id==='A').position.x,60);f.sessions.cancel();assert.equal(f.project().nodes.find(n=>n.id==='F').position.x,0);});
|
test('captured geometry previews remain coherent even after live update, and are shared across views',()=>{const f=fixture(),first=f.project();f.sessions.begin('w',[{id:'F',incarnation:1}]);assert.strictEqual(f.project(),first);f.sessions.update('w',{id:'F',incarnation:1},{x:60});const captured=f.sessions.getSnapshot();f.sessions.update('w',{id:'F',incarnation:1},{x:120});const old=f.project(captured),live=f.project();assert.equal(old.nodes.find(n=>n.id==='F').position.x,60);assert.equal(live.nodes.find(n=>n.id==='F').position.x,120);assert.equal(live.nodes.find(n=>n.id==='A').position.x-old.nodes.find(n=>n.id==='A').position.x,60);f.sessions.cancel();assert.equal(f.project().nodes.find(n=>n.id==='F').position.x,0);});
|
||||||
|
// 只有phase变committing不影响显示;path预览进入场景,取消使路径显示复原。
|
||||||
test('connection path preview and cancel invalidate, ownership-only phase does not',()=>{const f=fixture();f.sessions.beginConnection('w',{id:'edge',incarnation:1});const initial=f.project(),snapshot=f.sessions.getSnapshot();assert.strictEqual(f.project({...snapshot,connectionGesture:{...snapshot.connectionGesture,phase:'committing'}}),initial);const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:40},in:{x:-10,y:0},out:{x:10,y:0}}]};assert.equal(f.sessions.updateConnection('w',path).enabled,true);const preview=f.project();assert.deepEqual(preview.edges[0].data.path,path);f.sessions.cancel();assert.notStrictEqual(f.project(),preview);assert.equal(f.project().edges[0].data.path,undefined);});
|
test('connection path preview and cancel invalidate, ownership-only phase does not',()=>{const f=fixture();f.sessions.beginConnection('w',{id:'edge',incarnation:1});const initial=f.project(),snapshot=f.sessions.getSnapshot();assert.strictEqual(f.project({...snapshot,connectionGesture:{...snapshot.connectionGesture,phase:'committing'}}),initial);const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:40},in:{x:-10,y:0},out:{x:10,y:0}}]};assert.equal(f.sessions.updateConnection('w',path).enabled,true);const preview=f.project();assert.deepEqual(preview.edges[0].data.path,path);f.sessions.cancel();assert.notStrictEqual(f.project(),preview);assert.equal(f.project().edges[0].data.path,undefined);});
|
||||||
|
// 正文修改、scope或renderer替换、clear都不能返回过期场景,最新document必须随结果采用。
|
||||||
test('document and renderer/scope replacement and clearing cannot retain stale scene data',()=>{const f=fixture(),first=f.project();assert.equal(f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'edited'}}).status,'applied');const next=f.project();assert.equal(next.document,f.editor.getSnapshot().document);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'edited');assert.notStrictEqual(next,first);assert.notStrictEqual(f.project(undefined,undefined,{}),next);const previous=f.project(),custom={...f.catalog,resolve(element){const value=f.catalog.resolve(element);return value&&{...value,rendererId:'replacement'};}};assert.equal(f.project(undefined,undefined,undefined,custom).nodes[0].type,'replacement');f.scene.clear();assert.notStrictEqual(f.project(),previous);});
|
test('document and renderer/scope replacement and clearing cannot retain stale scene data',()=>{const f=fixture(),first=f.project();assert.equal(f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'edited'}}).status,'applied');const next=f.project();assert.equal(next.document,f.editor.getSnapshot().document);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'edited');assert.notStrictEqual(next,first);assert.notStrictEqual(f.project(undefined,undefined,{}),next);const previous=f.project(),custom={...f.catalog,resolve(element){const value=f.catalog.resolve(element);return value&&{...value,rendererId:'replacement'};}};assert.equal(f.project(undefined,undefined,undefined,custom).nodes[0].type,'replacement');f.scene.clear();assert.notStrictEqual(f.project(),previous);});
|
||||||
|
|
||||||
|
// 即使场景已缓存,也先拒绝错误document/registry的query,不可命中缓存后跳过来源检查。
|
||||||
test('a cached scene does not hide an invalid query provenance',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;f.project();const project=queries=>f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog,queries);assert.throws(()=>project({document:{...document},registry:f.registry}),/stale_query_snapshot/);assert.throws(()=>project({document,registry:{}}),/query_registry_changed/);assert.equal(f.project().document,document);});
|
test('a cached scene does not hide an invalid query provenance',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;f.project();const project=queries=>f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog,queries);assert.throws(()=>project({document:{...document},registry:f.registry}),/stale_query_snapshot/);assert.throws(()=>project({document,registry:{}}),/query_registry_changed/);assert.equal(f.project().document,document);});
|
||||||
|
|
||||||
|
// 连续两次B正文发布在下一次project前合并,renderer只解析B一次,A和edges引用保持。
|
||||||
test('continuous field publications rebuild only their dirty node, including several cuts before a frame',()=>{const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const initial=f.project(),reads=f.reads;for(const [id,text]of [['B','one'],['B','two']])assert.equal(f.editor.dispatch({type:'element.data',target:{id,incarnation:1},patch:{text}}).status,'applied');const next=f.project();assert.equal(f.reads-reads,1);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'two');assert.strictEqual(next.nodes.find(n=>n.id==='A'),initial.nodes.find(n=>n.id==='A'));assert.strictEqual(next.edges,initial.edges);stop();});
|
test('continuous field publications rebuild only their dirty node, including several cuts before a frame',()=>{const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const initial=f.project(),reads=f.reads;for(const [id,text]of [['B','one'],['B','two']])assert.equal(f.editor.dispatch({type:'element.data',target:{id,incarnation:1},patch:{text}}).status,'applied');const next=f.project();assert.equal(f.reads-reads,1);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'two');assert.strictEqual(next.nodes.find(n=>n.id==='A'),initial.nodes.find(n=>n.id==='A'));assert.strictEqual(next.edges,initial.edges);stop();});
|
||||||
|
// 等价新文档不再resolve,nodes/edges/transforms整体引用复用,但结果携新document。
|
||||||
test('equivalent canonical clones retain every projected object and numeric transform',()=>{const f=fixture(),first=f.project(),reads=f.reads;const document=createDocument('p',[...first.document.canvases.values()],[...first.document.elements.values()]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.equal(f.reads,reads);assert.strictEqual(result.nodes,first.nodes);assert.strictEqual(result.edges,first.edges);assert.strictEqual(result.transforms,first.transforms);assert.strictEqual(result.document,document);});
|
test('equivalent canonical clones retain every projected object and numeric transform',()=>{const f=fixture(),first=f.project(),reads=f.reads;const document=createDocument('p',[...first.document.canvases.values()],[...first.document.elements.values()]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.equal(f.reads,reads);assert.strictEqual(result.nodes,first.nodes);assert.strictEqual(result.edges,first.edges);assert.strictEqual(result.transforms,first.transforms);assert.strictEqual(result.document,document);});
|
||||||
|
// 先漏掉A的提交hint,再收到B后续hint,场景需检测基线不连续并找回两处正文,不信不完整dirty列表。
|
||||||
test('a missed publication cannot let an incomplete change hint hide a newer document',()=>{const f=fixture(),initial=f.project();f.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'missed'}});const middle=f.editor.getSnapshot().document;let later;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(middle,next.document,commit.changes);later=next.document;});f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'seen'}});const result=f.project();assert.notStrictEqual(result,initial);assert.strictEqual(result.document,later);assert.equal(result.nodes.find(n=>n.id==='A').data.text,'missed');assert.equal(result.nodes.find(n=>n.id==='B').data.text,'seen');stop();});
|
test('a missed publication cannot let an incomplete change hint hide a newer document',()=>{const f=fixture(),initial=f.project();f.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'missed'}});const middle=f.editor.getSnapshot().document;let later;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(middle,next.document,commit.changes);later=next.document;});f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'seen'}});const result=f.project();assert.notStrictEqual(result,initial);assert.strictEqual(result.document,later);assert.equal(result.nodes.find(n=>n.id==='A').data.text,'missed');assert.equal(result.nodes.find(n=>n.id==='B').data.text,'seen');stop();});
|
||||||
|
|
||||||
|
|
||||||
|
// 20轮正文/几何/选择/跨view预览/折叠缩放,后续增删改边和undo/redo,每个cut都与完整遍历比较。
|
||||||
test('mixed commands, hierarchy changes, selection and gestures match full traversal at every cut',()=>{
|
test('mixed commands, hierarchy changes, selection and gestures match full traversal at every cut',()=>{
|
||||||
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});
|
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});
|
||||||
|
/** 每个cut都按当前文档/会话完整投影,深比较增量结果,避免只验证节点数量或少数字段。 */
|
||||||
const compare=()=>{const document=f.editor.getSnapshot().document,snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),expected=projectCanvas(document,view.canvasId,f.registry,view.selection,id=>snapshot.gesture?.preview.get(id),snapshot.connectionGesture,f.catalog,{queries:f.editor.getQueries(document)});assert.deepEqual(f.project(),expected);};
|
const compare=()=>{const document=f.editor.getSnapshot().document,snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),expected=projectCanvas(document,view.canvasId,f.registry,view.selection,id=>snapshot.gesture?.preview.get(id),snapshot.connectionGesture,f.catalog,{queries:f.editor.getQueries(document)});assert.deepEqual(f.project(),expected);};
|
||||||
|
/** 要求命令applied后立即比较,失败信息包含命令及结果便于定位混合序列中的差异。 */
|
||||||
const dispatch=command=>{const result=f.editor.dispatch(command);assert.equal(result.status,'applied',JSON.stringify({command,result}));compare();};compare();
|
const dispatch=command=>{const result=f.editor.dispatch(command);assert.equal(result.status,'applied',JSON.stringify({command,result}));compare();};compare();
|
||||||
for(let i=0;i<20;i++){
|
for(let i=0;i<20;i++){
|
||||||
dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'step'+i}});
|
dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'step'+i}});
|
||||||
@@ -35,8 +52,10 @@ test('mixed commands, hierarchy changes, selection and gestures match full trave
|
|||||||
dispatch({type:'element.move',target:{id:'C',incarnation:1},canvasId:'child'});dispatch({type:'element.remove',target:{id:'C',incarnation:1}});assert.equal(f.editor.history.undo().status,'applied');compare();assert.equal(f.editor.history.redo().status,'applied');compare();stop();
|
dispatch({type:'element.move',target:{id:'C',incarnation:1},canvasId:'child'});dispatch({type:'element.remove',target:{id:'C',incarnation:1}});assert.equal(f.editor.history.undo().status,'applied');compare();assert.equal(f.editor.history.redo().status,'applied');compare();stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 1000节点缓存建好后禁止表遍历,500正文更新只能resolve一个renderer,邻居引用不变。
|
||||||
test('1000-node continuous update performs no document enumeration and resolves only one renderer',()=>{
|
test('1000-node continuous update performs no document enumeration and resolves only one renderer',()=>{
|
||||||
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let enumerate=true,resolves=0;
|
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let enumerate=true,resolves=0;
|
||||||
|
/** 首次允许遍历建立缓存,此后关闭枚举门,若局部更新退化成全表遍历则断言失败。 */
|
||||||
const protect=document=>({...document,elements:{size:document.elements.size,get:id=>document.elements.get(id),has:id=>document.elements.has(id),values(){assert.ok(enumerate,'unexpected whole-document values scan');return document.elements.values();},[Symbol.iterator](){assert.ok(enumerate,'unexpected whole-document iterator');return document.elements[Symbol.iterator]();}}});
|
const protect=document=>({...document,elements:{size:document.elements.size,get:id=>document.elements.get(id),has:id=>document.elements.has(id),values(){assert.ok(enumerate,'unexpected whole-document values scan');return document.elements.values();},[Symbol.iterator](){assert.ok(enumerate,'unexpected whole-document iterator');return document.elements[Symbol.iterator]();}}});
|
||||||
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},firstDoc=protect(original),first=scene.project(owner,firstDoc,view,snapshot,registry,catalog);
|
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},firstDoc=protect(original),first=scene.project(owner,firstDoc,view,snapshot,registry,catalog);
|
||||||
const elements=[...original.elements.values()].map(record=>record.id==='500'?{...record,data:{...record.data,text:'only target'}}:record),after=protect(createDocument('p',[...original.canvases.values()],elements));
|
const elements=[...original.elements.values()].map(record=>record.id==='500'?{...record,data:{...record.data,text:'only target'}}:record),after=protect(createDocument('p',[...original.canvases.values()],elements));
|
||||||
@@ -44,12 +63,16 @@ test('1000-node continuous update performs no document enumeration and resolves
|
|||||||
const next=scene.project(owner,after,view,snapshot,registry,catalog);assert.equal(resolves,1);assert.equal(next.nodes[500].data.text,'only target');assert.strictEqual(next.nodes[499],first.nodes[499]);assert.equal(next.document,after);
|
const next=scene.project(owner,after,view,snapshot,registry,catalog);assert.equal(resolves,1);assert.equal(next.nodes[500].data.text,'only target');assert.strictEqual(next.nodes[499],first.nodes[499]);assert.equal(next.document,after);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 无hint的ACK替换仍发现B正文,仅重建B;成员顺序反转后与完整投影次序一致。
|
||||||
test('replacement without changes compares records and detects reordered membership',()=>{const f=fixture(),initial=f.project(),doc=initial.document,records=[...doc.elements.values()].map(record=>record.id==='B'?{...record,data:{...record.data,text:'remote'}}:record),next=createDocument('p',[...doc.canvases.values()],records),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),reads=f.reads;f.scene.advance(doc,next,[]);const updated=f.scene.project(f.owner,next,view,snapshot,f.registry,f.catalog);assert.equal(f.reads-reads,1);assert.equal(updated.nodes.find(node=>node.id==='B').data.text,'remote');assert.strictEqual(updated.nodes.find(node=>node.id==='A'),initial.nodes.find(node=>node.id==='A'));const reordered=createDocument('p',[...next.canvases.values()],records.reverse());f.scene.advance(next,reordered,[]);assert.deepEqual(f.scene.project(f.owner,reordered,view,snapshot,f.registry,f.catalog),projectCanvas(reordered,'root',f.registry,view.selection,()=>undefined,null,f.catalog));});
|
test('replacement without changes compares records and detects reordered membership',()=>{const f=fixture(),initial=f.project(),doc=initial.document,records=[...doc.elements.values()].map(record=>record.id==='B'?{...record,data:{...record.data,text:'remote'}}:record),next=createDocument('p',[...doc.canvases.values()],records),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),reads=f.reads;f.scene.advance(doc,next,[]);const updated=f.scene.project(f.owner,next,view,snapshot,f.registry,f.catalog);assert.equal(f.reads-reads,1);assert.equal(updated.nodes.find(node=>node.id==='B').data.text,'remote');assert.strictEqual(updated.nodes.find(node=>node.id==='A'),initial.nodes.find(node=>node.id==='A'));const reordered=createDocument('p',[...next.canvases.values()],records.reverse());f.scene.advance(next,reordered,[]);assert.deepEqual(f.scene.project(f.owner,reordered,view,snapshot,f.registry,f.catalog),projectCanvas(reordered,'root',f.registry,view.selection,()=>undefined,null,f.catalog));});
|
||||||
|
|
||||||
|
// 选A/B时B解析抛错,先前场景两者selected仍false;解除错误后再完整生成选中场景。
|
||||||
test('a failed local renderer never mutates or publishes a partially updated scene',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;let fail=false;const catalog={...f.catalog,resolve(record){if(fail&&record.id==='B')throw Error('renderer failed');return f.catalog.resolve(record);}},first=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,catalog);f.sessions.select('v',['A','B']);const selected=f.sessions.getSnapshot();fail=true;assert.throws(()=>f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog),/renderer failed/);assert.equal(first.nodes.find(node=>node.id==='A').selected,false);assert.equal(first.nodes.find(node=>node.id==='B').selected,false);fail=false;const result=f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog);assert.equal(result.nodes.find(node=>node.id==='A').selected,true);assert.equal(result.nodes.find(node=>node.id==='B').selected,true);});
|
test('a failed local renderer never mutates or publishes a partially updated scene',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;let fail=false;const catalog={...f.catalog,resolve(record){if(fail&&record.id==='B')throw Error('renderer failed');return f.catalog.resolve(record);}},first=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,catalog);f.sessions.select('v',['A','B']);const selected=f.sessions.getSnapshot();fail=true;assert.throws(()=>f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog),/renderer failed/);assert.equal(first.nodes.find(node=>node.id==='A').selected,false);assert.equal(first.nodes.find(node=>node.id==='B').selected,false);fail=false;const result=f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog);assert.equal(result.nodes.find(node=>node.id==='A').selected,true);assert.equal(result.nodes.find(node=>node.id==='B').selected,true);});
|
||||||
|
|
||||||
|
// 未来版本F作为unsupported,边源重定向F;B局部改动仍保留完整占位/端口规则及opaque原内容。
|
||||||
test('unknown placeholders and redirected handles keep the complete projection rules during a neighbour edit',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),base=f.editor.getSnapshot().document,records=[...base.elements.values()].map(record=>record.id==='F'?{...record,dataVersion:99,data:{unreadable:{preserve:true}}}:record.id==='edge'?{...record,source:{elementId:'A',portId:'unknown-custom'},target:{elementId:'B'}}:record),document=createDocument('p',[...base.canvases.values()],records),first=f.scene.project(f.owner,document,view,snapshot,f.registry,f.catalog),changed=createDocument('p',[...document.canvases.values()],records.map(record=>record.id==='B'?{...record,data:{...record.data,text:'still editable'}}:record));assert.equal(first.nodes.find(node=>node.id==='F').type,'unsupported');assert.equal(first.edges[0].source,'F');f.scene.advance(document,changed,[{kind:'field',target:{id:'B',incarnation:1},field:'data.text',before:'',after:'still editable'}]);const result=f.scene.project(f.owner,changed,view,snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(changed,'root',f.registry,view.selection,()=>undefined,null,f.catalog));assert.deepEqual(result.document.elements.get('F').data,{unreadable:{preserve:true}});});
|
test('unknown placeholders and redirected handles keep the complete projection rules during a neighbour edit',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),base=f.editor.getSnapshot().document,records=[...base.elements.values()].map(record=>record.id==='F'?{...record,dataVersion:99,data:{unreadable:{preserve:true}}}:record.id==='edge'?{...record,source:{elementId:'A',portId:'unknown-custom'},target:{elementId:'B'}}:record),document=createDocument('p',[...base.canvases.values()],records),first=f.scene.project(f.owner,document,view,snapshot,f.registry,f.catalog),changed=createDocument('p',[...document.canvases.values()],records.map(record=>record.id==='B'?{...record,data:{...record.data,text:'still editable'}}:record));assert.equal(first.nodes.find(node=>node.id==='F').type,'unsupported');assert.equal(first.edges[0].source,'F');f.scene.advance(document,changed,[{kind:'field',target:{id:'B',incarnation:1},field:'data.text',before:'',after:'still editable'}]);const result=f.scene.project(f.owner,changed,view,snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(changed,'root',f.registry,view.selection,()=>undefined,null,f.catalog));assert.deepEqual(result.document.elements.get('F').data,{unreadable:{preserve:true}});});
|
||||||
|
|
||||||
|
// 同批增root/child节点并改B,只resolve三项,嵌套按深度优先顺序插入,既有F/A/edges引用复用。
|
||||||
test('new ordinary cards reuse existing projections and preserve depth-first nested membership order',()=>{
|
test('new ordinary cards reuse existing projections and preserve depth-first nested membership order',()=>{
|
||||||
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
|
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
|
||||||
assert.equal(f.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-root')});tx.dispatch({type:'element.create',element:node('new-child','child')});tx.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'same batch'}});}).status,'applied');
|
assert.equal(f.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-root')});tx.dispatch({type:'element.create',element:node('new-child','child')});tx.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'same batch'}});}).status,'applied');
|
||||||
@@ -58,18 +81,21 @@ test('new ordinary cards reuse existing projections and preserve depth-first nes
|
|||||||
const beforeEdit=f.reads;f.editor.dispatch({type:'element.data',target:{id:'new-child',incarnation:1},patch:{text:'later edit'}});const edited=f.project();assert.equal(f.reads-beforeEdit,1);assert.equal(edited.nodes.find(n=>n.id==='new-child').data.text,'later edit');stop();
|
const beforeEdit=f.reads;f.editor.dispatch({type:'element.data',target:{id:'new-child',incarnation:1},patch:{text:'later edit'}});const edited=f.project();assert.equal(f.reads-beforeEdit,1);assert.equal(edited.nodes.find(n=>n.id==='new-child').data.text,'later edit');stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 折叠child里新增节点只进document,不重建可见数组;展开F时它出现并与完整遍历一致。
|
||||||
test('new hidden cards do not reconstruct visible objects and appear correctly after expanding their entry',()=>{
|
test('new hidden cards do not reconstruct visible objects and appear correctly after expanding their entry',()=>{
|
||||||
const f=fixture();f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
|
const f=fixture();f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const first=f.project(),reads=f.reads;
|
||||||
f.editor.dispatch({type:'element.create',element:node('hidden-new','child')});const next=f.project();assert.equal(f.reads,reads);assert.strictEqual(next.nodes,first.nodes);assert.strictEqual(next.edges,first.edges);assert.ok(next.document.elements.has('hidden-new'));
|
f.editor.dispatch({type:'element.create',element:node('hidden-new','child')});const next=f.project();assert.equal(f.reads,reads);assert.strictEqual(next.nodes,first.nodes);assert.strictEqual(next.edges,first.edges);assert.ok(next.document.elements.has('hidden-new'));
|
||||||
f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:false}}});const expanded=f.project();assert.ok(expanded.nodes.some(n=>n.id==='hidden-new'));const snapshot=f.sessions.getSnapshot();assert.deepEqual(expanded,projectCanvas(expanded.document,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));stop();
|
f.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:false}}});const expanded=f.project();assert.ok(expanded.nodes.some(n=>n.id==='hidden-new'));const snapshot=f.sessions.getSnapshot();assert.deepEqual(expanded,projectCanvas(expanded.document,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 提供members索引并禁文档枚举,增一张卡只resolve它,保留既有1000节点及edges引用。
|
||||||
test('inserting one card into 1000 nodes resolves only that renderer and never enumerates document records with a membership index',()=>{
|
test('inserting one card into 1000 nodes resolves only that renderer and never enumerates document records with a membership index',()=>{
|
||||||
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let resolves=0;
|
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let resolves=0;
|
||||||
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},first=scene.project(owner,original,view,snapshot,registry,catalog),added=node('added'),raw=createDocument('p',[...original.canvases.values()],[...original.elements.values(),added]),document={...raw,elements:{size:raw.elements.size,get:id=>raw.elements.get(id),has:id=>raw.elements.has(id),values(){throw Error('whole-document enumeration');},[Symbol.iterator](){throw Error('whole-document enumeration');}}},queries={document,registry,members:()=>[...raw.elements.keys()]};
|
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},first=scene.project(owner,original,view,snapshot,registry,catalog),added=node('added'),raw=createDocument('p',[...original.canvases.values()],[...original.elements.values(),added]),document={...raw,elements:{size:raw.elements.size,get:id=>raw.elements.get(id),has:id=>raw.elements.has(id),values(){throw Error('whole-document enumeration');},[Symbol.iterator](){throw Error('whole-document enumeration');}}},queries={document,registry,members:()=>[...raw.elements.keys()]};
|
||||||
scene.advance(original,document,[{kind:'element',id:'added',before:null,after:added}]);resolves=0;const next=scene.project(owner,document,view,snapshot,registry,catalog,queries);assert.equal(resolves,1);assert.equal(next.nodes.length,1001);assert.equal(next.nodes.at(-1).id,'added');for(let i=0;i<1000;i++)assert.strictEqual(next.nodes[i],first.nodes[i]);assert.strictEqual(next.edges,first.edges);
|
scene.advance(original,document,[{kind:'element',id:'added',before:null,after:added}]);resolves=0;const next=scene.project(owner,document,view,snapshot,registry,catalog,queries);assert.equal(resolves,1);assert.equal(next.nodes.length,1001);assert.equal(next.nodes.at(-1).id,'added');for(let i=0;i<1000;i++)assert.strictEqual(next.nodes[i],first.nodes[i]);assert.strictEqual(next.edges,first.edges);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 分别新增已知/未知卡片及关联边,增量路径须与完整投影保持相同端点/未来端口占位规则。
|
||||||
test('mixed edge insertion and unknown new cards retain full endpoint and placeholder projection rules',()=>{
|
test('mixed edge insertion and unknown new cards retain full endpoint and placeholder projection rules',()=>{
|
||||||
for(const unknown of [false,true]){const f=fixture(),before=f.editor.getSnapshot().document;f.project();const added=unknown?{...node('added'),componentId:'future',dataVersion:99,data:{opaque:{keep:true}}}:node('added'),edge={id:'new-edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'added',...(unknown?{portId:'future-port'}:{})},data:{},path:null},after=createDocument('p',[...before.canvases.values()],[...before.elements.values(),added,edge]);f.scene.advance(before,after,[{kind:'element',id:added.id,before:null,after:added},{kind:'element',id:edge.id,before:null,after:edge}]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,after,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(after,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));}
|
for(const unknown of [false,true]){const f=fixture(),before=f.editor.getSnapshot().document;f.project();const added=unknown?{...node('added'),componentId:'future',dataVersion:99,data:{opaque:{keep:true}}}:node('added'),edge={id:'new-edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'added',...(unknown?{portId:'future-port'}:{})},data:{},path:null},after=createDocument('p',[...before.canvases.values()],[...before.elements.values(),added,edge]);f.scene.advance(before,after,[{kind:'element',id:added.id,before:null,after:added},{kind:'element',id:edge.id,before:null,after:edge}]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,after,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(after,'root',f.registry,snapshot.views.get('v').selection,()=>undefined,null,f.catalog));}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 调度器状态机测试:从可变操作生成冻结请求,经持久化屏障后派发,未知结果走查询及原请求重试。
|
||||||
|
* 内存账本、可控 Promise 和假时钟隔离网络、磁盘与真实时间,验证 writer 占用、依赖及停止边界。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
||||||
import { createScheduler } from '../../src/lib/canvas-runtime/scheduler.ts';
|
import { createScheduler } from '../../src/lib/canvas-runtime/scheduler.ts';
|
||||||
|
|
||||||
|
// 手动控制 Promise 完成时刻,让测试停在落盘、上传或服务端回执尚未完成的位置。
|
||||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||||
|
// 推进有限轮微任务供调度泵运行;不推进墙上时间,也不替代 scheduler.settle 的收敛等待。
|
||||||
const tick = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
const tick = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
||||||
|
// 建立可替换端口的调度环境;mutate 使用账本副本发布状态,persist 仅记录快照,默认服务立即成功。
|
||||||
function setup(overrides = {}, initial) {
|
function setup(overrides = {}, initial) {
|
||||||
let ledger = createLedger(initial), time = 1000, next = 0, timerId = 0;
|
let ledger = createLedger(initial), time = 1000, next = 0, timerId = 0;
|
||||||
const timers = new Map(), calls = [], persisted = [], errors = [];
|
const timers = new Map(), calls = [], persisted = [], errors = [];
|
||||||
const scheduler = createScheduler({
|
const scheduler = createScheduler({
|
||||||
getLedger: () => ledger.getSnapshot(),
|
getLedger: () => ledger.getSnapshot(),
|
||||||
|
// 模拟提交边界:先在私有账本运行 reducer,成功后才替换供 scheduler 读取的当前账本。
|
||||||
mutate(reduce) { const draft = createLedger(ledger.getSnapshot()); reduce(draft); ledger = draft; },
|
mutate(reduce) { const draft = createLedger(ledger.getSnapshot()); reduce(draft); ledger = draft; },
|
||||||
async persist() { persisted.push(ledger.getSnapshot()); },
|
async persist() { persisted.push(ledger.getSnapshot()); },
|
||||||
createId: () => `request-${++next}`,
|
createId: () => `request-${++next}`,
|
||||||
@@ -18,17 +26,21 @@ function setup(overrides = {}, initial) {
|
|||||||
async submit(request) { calls.push(['submit', request]); return { outcome: 'applied' }; },
|
async submit(request) { calls.push(['submit', request]); return { outcome: 'applied' }; },
|
||||||
async query(request) { calls.push(['query', request]); return { outcome: 'applied' }; },
|
async query(request) { calls.push(['query', request]); return { outcome: 'applied' }; },
|
||||||
complete(request, status) { const draft = createLedger(ledger.getSnapshot()); draft.result(request.id, status.outcome, status.error); ledger = draft; },
|
complete(request, status) { const draft = createLedger(ledger.getSnapshot()); draft.result(request.id, status.outcome, status.error); ledger = draft; },
|
||||||
|
// 假时钟仅登记定时器,advance 才触发到期回调;retryDelay 固定为 100 便于检查查询与重试次序。
|
||||||
clock: { now: () => time, setTimer(callback, delay) { const id = ++timerId; timers.set(id, { callback, at: time + delay }); return id; }, clearTimer: id => timers.delete(id) },
|
clock: { now: () => time, setTimer(callback, delay) { const id = ++timerId; timers.set(id, { callback, at: time + delay }); return id; }, clearTimer: id => timers.delete(id) },
|
||||||
retryDelay: () => 100,
|
retryDelay: () => 100,
|
||||||
onError: error => errors.push(error), ...overrides,
|
onError: error => errors.push(error), ...overrides,
|
||||||
});
|
});
|
||||||
return { scheduler, calls, persisted, errors, ledger: () => ledger,
|
return { scheduler, calls, persisted, errors, ledger: () => ledger,
|
||||||
|
// 添加文档操作并通知调度器;测试用 changes 的 ID 同时表示参与 canvas,省去真实文档编译。
|
||||||
add(id, canvases = ['canvas'], dependencies = []) { ledger.add({ id, operationType: 'document.change', operationSchemaVersion: 1, sequence: ledger.getSnapshot().operations.size + 1, origin: 'user', changes: canvases.map(id => ({ kind: 'canvas', id, before: null, after: { id, parent: null } })), dependencies }, {}); scheduler.notify([id]); },
|
add(id, canvases = ['canvas'], dependencies = []) { ledger.add({ id, operationType: 'document.change', operationSchemaVersion: 1, sequence: ledger.getSnapshot().operations.size + 1, origin: 'user', changes: canvases.map(id => ({ kind: 'canvas', id, before: null, after: { id, parent: null } })), dependencies }, {}); scheduler.notify([id]); },
|
||||||
|
// 先移动时间并执行当前已到期回调,再推进微任务;有意保持网络挂起时可跳过 settle。
|
||||||
async advance(ms, settle = true) { time += ms; for (const [id, timer] of [...timers]) if (timer.at <= time) { timers.delete(id); timer.callback(); } await tick(); if (settle) await scheduler.settle(); },
|
async advance(ms, settle = true) { time += ms; for (const [id, timer] of [...timers]) if (timer.at <= time) { timers.delete(id); timer.callback(); } await tick(); if (settle) await scheduler.settle(); },
|
||||||
timerCount: () => timers.size,
|
timerCount: () => timers.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用持久化屏障挡住网络;冻结请求已占 writer,但屏障解除后才允许发送,并在成功后唤醒依赖删除。
|
||||||
test('freeze and dispatch evidence must be durable before submit, then dependencies wake', async () => {
|
test('freeze and dispatch evidence must be durable before submit, then dependencies wake', async () => {
|
||||||
const barrier = deferred(), sent = [], state = setup({ persist: () => barrier.promise, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
const barrier = deferred(), sent = [], state = setup({ persist: () => barrier.promise, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
||||||
state.add('create'); state.add('delete', ['canvas'], ['create']); state.scheduler.start(); await tick();
|
state.add('create'); state.add('delete', ['canvas'], ['create']); state.scheduler.start(); await tick();
|
||||||
@@ -40,6 +52,7 @@ test('freeze and dispatch evidence must be durable before submit, then dependenc
|
|||||||
assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 一个 canvas 的未知结果保持占用;其他 canvas 可继续,accepted 仍需查询,直到 applied 才放行同 canvas 后续操作。
|
||||||
test('unknown request holds its canvas; another canvas proceeds; accepted stays on query path', async () => {
|
test('unknown request holds its canvas; another canvas proceeds; accepted stays on query path', async () => {
|
||||||
const calls = []; let queries = 0;
|
const calls = []; let queries = 0;
|
||||||
const state = setup({
|
const state = setup({
|
||||||
@@ -56,6 +69,7 @@ test('unknown request holds its canvas; another canvas proceeds; accepted stays
|
|||||||
assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 恢复后先查回执;回执未知才重发,查询和重发都必须沿用保存的 ID 与字节,不能新建第二个请求。
|
||||||
test('receipt missing after recovery triggers exact retry, never a new identity/body', async () => {
|
test('receipt missing after recovery triggers exact retry, never a new identity/body', async () => {
|
||||||
const original = setup({ submit: async () => ({ outcome: 'unresolved' }) });
|
const original = setup({ submit: async () => ({ outcome: 'unresolved' }) });
|
||||||
original.add('create'); original.scheduler.start(); await original.scheduler.settle();
|
original.add('create'); original.scheduler.start(); await original.scheduler.settle();
|
||||||
@@ -68,6 +82,7 @@ test('receipt missing after recovery triggers exact retry, never a new identity/
|
|||||||
assert.equal(restored.ledger().getSnapshot().requests.size, 1); restored.scheduler.stop();
|
assert.equal(restored.ledger().getSnapshot().requests.size, 1); restored.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 落盘失败时不发送且不释放占用;显式恢复先重新持久化,再按已有派发证据保守查询。
|
||||||
test('storage failure never sends and keeps claims; explicit resume retries persistence first', async () => {
|
test('storage failure never sends and keeps claims; explicit resume retries persistence first', async () => {
|
||||||
let broken = true; const sent = [];
|
let broken = true; const sent = [];
|
||||||
const state = setup({ persist: async () => { if (broken) throw new Error('quota'); }, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
const state = setup({ persist: async () => { if (broken) throw new Error('quota'); }, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
||||||
@@ -78,6 +93,7 @@ test('storage failure never sends and keeps claims; explicit resume retries pers
|
|||||||
assert.equal(state.calls[0][0], 'query'); assert.equal(sent.length, 0); assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
assert.equal(state.calls[0][0], 'query'); assert.equal(sent.length, 0); assert.equal(state.scheduler.getClaims().size, 0); state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 跨两个 canvas 的操作同时持有两个 writer;停止后的迟到成功不能写回账本,也不能重新启动已关闭实例。
|
||||||
test('multi-canvas claims are atomic and stop fences late results', async () => {
|
test('multi-canvas claims are atomic and stop fences late results', async () => {
|
||||||
const response = deferred(), state = setup({ submit: () => response.promise });
|
const response = deferred(), state = setup({ submit: () => response.promise });
|
||||||
state.add('cross', ['a', 'b']); state.add('single', ['b']); state.scheduler.start(); await tick();
|
state.add('cross', ['a', 'b']); state.add('single', ['b']); state.scheduler.start(); await tick();
|
||||||
@@ -87,6 +103,7 @@ test('multi-canvas claims are atomic and stop fences late results', async () =>
|
|||||||
assert.throws(() => state.scheduler.start(), /scheduler_closed/);
|
assert.throws(() => state.scheduler.start(), /scheduler_closed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 无法解释未来协议请求时保留原字节和 writer,并标记不可解释;无关 canvas 的新操作仍可完成。
|
||||||
test('missing frozen-request interpreter preserves bytes and claims while unrelated work proceeds', async () => {
|
test('missing frozen-request interpreter preserves bytes and claims while unrelated work proceeds', async () => {
|
||||||
const initial = setup({ submit: async () => ({ outcome: 'unresolved' }) }); initial.add('create'); initial.scheduler.start(); await initial.scheduler.settle(); initial.scheduler.stop();
|
const initial = setup({ submit: async () => ({ outcome: 'unresolved' }) }); initial.add('create'); initial.scheduler.start(); await initial.scheduler.settle(); initial.scheduler.stop();
|
||||||
const saved = initial.ledger().getSnapshot(), requests = new Map(saved.requests), old = requests.get('request-1');
|
const saved = initial.ledger().getSnapshot(), requests = new Map(saved.requests), old = requests.get('request-1');
|
||||||
@@ -99,6 +116,7 @@ test('missing frozen-request interpreter preserves bytes and claims while unrela
|
|||||||
assert.equal(restored.scheduler.getClaims().get('canvas'), old.id); restored.scheduler.stop();
|
assert.equal(restored.scheduler.getClaims().get('canvas'), old.id); restored.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 身份变化等阻塞结果没有证明零写入,故保留未知占用;同时停止自动重试,避免无权限的循环请求。
|
||||||
test('permission/protocol rejection keeps unknown claims without an automatic retry loop', async()=>{
|
test('permission/protocol rejection keeps unknown claims without an automatic retry loop', async()=>{
|
||||||
const calls=[],state=setup({submit:async request=>{calls.push(request);return{outcome:'unresolved',blocked:true,error:'identity_changed'}}});
|
const calls=[],state=setup({submit:async request=>{calls.push(request);return{outcome:'unresolved',blocked:true,error:'identity_changed'}}});
|
||||||
state.add('create');state.scheduler.start();await state.scheduler.settle();
|
state.add('create');state.scheduler.start();await state.scheduler.settle();
|
||||||
@@ -106,12 +124,15 @@ test('permission/protocol rejection keeps unknown claims without an automatic re
|
|||||||
await state.advance(10000);assert.equal(calls.length,1);state.scheduler.stop();
|
await state.advance(10000);assert.equal(calls.length,1);state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 混合测试编译器:上传不占 canvas writer,文档操作按变化 ID 占用,二者仍共享网络调度约束。
|
||||||
const mixedCompile = (intent, requestId) => ({ kind: intent.operationType, protocolVersion: 2, canvasIds: intent.operationType === 'asset.upload' ? [] : intent.changes.map(change => change.id), expectedVersions: Object.fromEntries(intent.changes.map(change => [change.id, 1])), payload: { requestId, operationId: intent.id } });
|
const mixedCompile = (intent, requestId) => ({ kind: intent.operationType, protocolVersion: 2, canvasIds: intent.operationType === 'asset.upload' ? [] : intent.changes.map(change => change.id), expectedVersions: Object.fromEntries(intent.changes.map(change => [change.id, 1])), payload: { requestId, operationId: intent.id } });
|
||||||
|
// 插入独立资源上传并通知调度器,用于验证上传容量与文档任务的公平性。
|
||||||
function addAsset(state, id) {
|
function addAsset(state, id) {
|
||||||
state.ledger().add({ id, operationType: 'asset.upload', operationSchemaVersion: 1, sequence: state.ledger().getSnapshot().operations.size + 1, origin: 'user', changes: [], canvasIds: [], dependencies: [], resourceId: `resource-${id}` }, {});
|
state.ledger().add({ id, operationType: 'asset.upload', operationSchemaVersion: 1, sequence: state.ledger().getSnapshot().operations.size + 1, origin: 'user', changes: [], canvasIds: [], dependencies: [], resourceId: `resource-${id}` }, {});
|
||||||
state.scheduler.notify([id]);
|
state.scheduler.notify([id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 80 个上传排队且首个挂起时,独立文档仍能进入网络;解除上传屏障后全部完成,无额外错误。
|
||||||
test('an upload burst yields without filling both network slots or starving independent edits', async () => {
|
test('an upload burst yields without filling both network slots or starving independent edits', async () => {
|
||||||
const gate = deferred(), calls = [];
|
const gate = deferred(), calls = [];
|
||||||
const state = setup({ compile: mixedCompile, submit: async request => { calls.push(request); if (request.operationIds[0] === 'asset-0') await gate.promise; return { outcome: 'applied' }; } });
|
const state = setup({ compile: mixedCompile, submit: async request => { calls.push(request); if (request.operationIds[0] === 'asset-0') await gate.promise; return { outcome: 'applied' }; } });
|
||||||
@@ -124,6 +145,7 @@ test('an upload burst yields without filling both network slots or starving inde
|
|||||||
assert.equal(calls.length, 81); assert.equal(state.errors.length, 0); state.scheduler.stop();
|
assert.equal(calls.length, 81); assert.equal(state.errors.length, 0); state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 上传容量已满时不反复唤醒同一重试;更晚到期的文档查询仍能执行,容量释放后再恢复上传查询。
|
||||||
test('a capacity-blocked upload retry does not spin or hide a later document retry deadline', async () => {
|
test('a capacity-blocked upload retry does not spin or hide a later document retry deadline', async () => {
|
||||||
const gate = deferred(), calls = [];
|
const gate = deferred(), calls = [];
|
||||||
const state = setup({ compile: mixedCompile,
|
const state = setup({ compile: mixedCompile,
|
||||||
@@ -144,6 +166,7 @@ test('a capacity-blocked upload retry does not spin or hide a later document ret
|
|||||||
assert.ok(calls.some(([action, id]) => action === 'query' && id === 'asset-1')); state.scheduler.stop();
|
assert.ok(calls.some(([action, id]) => action === 'query' && id === 'asset-1')); state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 嵌套 hold 不取消已进入网络的请求,但阻止新派发和到期重试;重复释放无效,最后一个 hold 释放才恢复。
|
||||||
test('nested holds finish admitted receipts but suppress dependent dispatch and due retries until the final release', async () => {
|
test('nested holds finish admitted receipts but suppress dependent dispatch and due retries until the final release', async () => {
|
||||||
const response = deferred(), calls = [];
|
const response = deferred(), calls = [];
|
||||||
const state = setup({ concurrency: 1, submit: async request => { calls.push(['submit', request]); return request.operationIds[0] === 'first' ? response.promise : { outcome: 'applied' }; }, query: async request => { calls.push(['query', request]); return { outcome: 'applied' }; } });
|
const state = setup({ concurrency: 1, submit: async request => { calls.push(['submit', request]); return request.operationIds[0] === 'first' ? response.promise : { outcome: 'applied' }; }, query: async request => { calls.push(['query', request]); return { outcome: 'applied' }; } });
|
||||||
@@ -160,6 +183,7 @@ test('nested holds finish admitted receipts but suppress dependent dispatch and
|
|||||||
state.scheduler.stop();
|
state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 启动前 hold 保留已通知工作;stop 是终态,之后释放 hold 也不能复活调度器。
|
||||||
test('holds acquired before start retain notified work; stop is terminal even when a hold is later released', async () => {
|
test('holds acquired before start retain notified work; stop is terminal even when a hold is later released', async () => {
|
||||||
const state = setup(), release = state.scheduler.hold(); state.add('first'); state.scheduler.start(); await tick();
|
const state = setup(), release = state.scheduler.hold(); state.add('first'); state.scheduler.start(); await tick();
|
||||||
assert.equal(state.calls.length, 0); release(); await state.scheduler.settle(); assert.equal(state.calls.length, 1);
|
assert.equal(state.calls.length, 0); release(); await state.scheduler.settle(); assert.equal(state.calls.length, 1);
|
||||||
@@ -167,11 +191,13 @@ test('holds acquired before start retain notified work; stop is terminal even wh
|
|||||||
assert.equal(state.calls.length, 1); assert.throws(() => state.scheduler.hold(), /scheduler_closed/);
|
assert.equal(state.calls.length, 1); assert.throws(() => state.scheduler.hold(), /scheduler_closed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 批量编译仅打包逻辑操作并汇总排序后的 canvas;这里不承担真实 wire 字段压缩。
|
||||||
const compileBatch = (intents, requestId) => {
|
const compileBatch = (intents, requestId) => {
|
||||||
const canvasIds = [...new Set(intents.flatMap(intent => intent.changes.map(change => change.id)))].sort();
|
const canvasIds = [...new Set(intents.flatMap(intent => intent.changes.map(change => change.id)))].sort();
|
||||||
return { canvasIds, expectedVersions: Object.fromEntries(canvasIds.map(id => [id, 1])), payload: { requestId, operations: intents.map(intent => ({ id: intent.id, changes: intent.changes })) } };
|
return { canvasIds, expectedVersions: Object.fromEntries(canvasIds.map(id => [id, 1])), payload: { requestId, operations: intents.map(intent => ({ id: intent.id, changes: intent.changes })) } };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 同一微任务窗口内的依赖链可打成一批;整批共享持久化屏障,成功后所有逻辑操作一并解决。
|
||||||
test('mutable microtask window batches dependent document IDs behind one durable dispatch barrier', async () => {
|
test('mutable microtask window batches dependent document IDs behind one durable dispatch barrier', async () => {
|
||||||
const barrier = deferred(), sent = [];
|
const barrier = deferred(), sent = [];
|
||||||
const state = setup({ compileBatch, persist: () => barrier.promise, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
const state = setup({ compileBatch, persist: () => barrier.promise, submit: async request => { sent.push(request); return { outcome: 'applied' }; } });
|
||||||
@@ -187,6 +213,7 @@ test('mutable microtask window batches dependent document IDs behind one durable
|
|||||||
state.scheduler.stop();
|
state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 先验证不同 canvas 通道不会任意并批,再加入观察前序操作的 run 记录,验证它形成批次边界。
|
||||||
test('batching retains independent lanes and does not cross an external dependency or task observer', async () => {
|
test('batching retains independent lanes and does not cross an external dependency or task observer', async () => {
|
||||||
const pending = deferred(), sent = [], state = setup({ compileBatch, submit: async request => { sent.push(request); return pending.promise; } });
|
const pending = deferred(), sent = [], state = setup({ compileBatch, submit: async request => { sent.push(request); return pending.promise; } });
|
||||||
state.add('a1', ['a']); state.add('b1', ['b']); state.add('a2', ['a'], ['a1']);
|
state.add('a1', ['a']); state.add('b1', ['b']); state.add('a2', ['a'], ['a1']);
|
||||||
@@ -203,6 +230,7 @@ test('batching retains independent lanes and does not cross an external dependen
|
|||||||
assert.deepEqual(observed.calls.map(([, request]) => request.operationIds), [['before'], ['after']]); observed.scheduler.stop();
|
assert.deepEqual(observed.calls.map(([, request]) => request.operationIds), [['before'], ['after']]); observed.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未知批次冻结全部逻辑 ID 和原始请求体;后来编辑保持 pending,查询及重发不能吸收它。
|
||||||
test('unknown batch preserves all IDs, claims, and exact bytes across query and retry; later edits stay mutable', async () => {
|
test('unknown batch preserves all IDs, claims, and exact bytes across query and retry; later edits stay mutable', async () => {
|
||||||
const sent = [], state = setup({ compileBatch, submit: async request => { sent.push(request); return { outcome: 'unresolved' }; }, query: async () => ({ outcome: 'unresolved' }) });
|
const sent = [], state = setup({ compileBatch, submit: async request => { sent.push(request); return { outcome: 'unresolved' }; }, query: async () => ({ outcome: 'unresolved' }) });
|
||||||
state.add('one'); state.add('two', ['canvas'], ['one']); state.scheduler.start(); await state.scheduler.settle();
|
state.add('one'); state.add('two', ['canvas'], ['one']); state.scheduler.start(); await state.scheduler.settle();
|
||||||
@@ -213,6 +241,7 @@ test('unknown batch preserves all IDs, claims, and exact bytes across query and
|
|||||||
assert.equal(state.scheduler.getClaims().get('canvas'), original.id); state.scheduler.stop();
|
assert.equal(state.scheduler.getClaims().get('canvas'), original.id); state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// hold 阻止批次入场;释放后 130 个依赖操作按 64、64、2 排空,不依赖推进假定时器。
|
||||||
test('bounded batches drain through settle without waiting for fake timers and holds suppress scheduled admission', async () => {
|
test('bounded batches drain through settle without waiting for fake timers and holds suppress scheduled admission', async () => {
|
||||||
const state = setup({ compileBatch }); state.scheduler.start(); const release = state.scheduler.hold();
|
const state = setup({ compileBatch }); state.scheduler.start(); const release = state.scheduler.hold();
|
||||||
for (let i = 0; i < 130; i++) state.add(`op-${i}`, ['canvas'], i ? [`op-${i - 1}`] : []);
|
for (let i = 0; i < 130; i++) state.add(`op-${i}`, ['canvas'], i ? [`op-${i - 1}`] : []);
|
||||||
@@ -222,17 +251,20 @@ test('bounded batches drain through settle without waiting for fake timers and h
|
|||||||
assert.equal(state.ledger().getSnapshot().operations.get('op-129').phase, 'resolved'); state.scheduler.stop();
|
assert.equal(state.ledger().getSnapshot().operations.get('op-129').phase, 'resolved'); state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 状态发布因存储 leader 丢失而失败时,错误通过 onError 报告;操作保持 pending,不能生成或发送请求。
|
||||||
test('a fenced pump reports failed state publication without throwing or sending through the old identity',async()=>{
|
test('a fenced pump reports failed state publication without throwing or sending through the old identity',async()=>{
|
||||||
const state=setup({mutate(){throw Error('storage_leader_lost');}});state.scheduler.start();state.add('fenced');await tick();await state.scheduler.settle();assert.equal(state.calls.length,0);assert.equal(state.ledger().getSnapshot().requests.size,0);assert.equal(state.ledger().getSnapshot().operations.get('fenced').phase,'pending');assert.ok(state.errors.some(error=>error.message==='storage_leader_lost'));state.scheduler.stop();
|
const state=setup({mutate(){throw Error('storage_leader_lost');}});state.scheduler.start();state.add('fenced');await tick();await state.scheduler.settle();assert.equal(state.calls.length,0);assert.equal(state.ledger().getSnapshot().requests.size,0);assert.equal(state.ledger().getSnapshot().operations.get('fenced').phase,'pending');assert.ok(state.errors.some(error=>error.message==='storage_leader_lost'));state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wall time can change independently of the elapsed time used by browser timers.
|
// Wall time can change independently of the elapsed time used by browser timers.
|
||||||
|
// 分离墙上时间与定时器经过时间:可单独跳动 wall,或推进 elapsed 并返回旧回调来模拟迟到触发。
|
||||||
function adjustableClock(initial=1000){
|
function adjustableClock(initial=1000){
|
||||||
let wall=initial,elapsed=0,next=0;const timers=new Map();
|
let wall=initial,elapsed=0,next=0;const timers=new Map();
|
||||||
return{clock:{now:()=>wall,setTimer(callback,delay){const id=++next;timers.set(id,{callback,delay,at:elapsed+delay});return id;},clearTimer:id=>timers.delete(id)},timers,
|
return{clock:{now:()=>wall,setTimer(callback,delay){const id=++next;timers.set(id,{callback,delay,at:elapsed+delay});return id;},clearTimer:id=>timers.delete(id)},timers,
|
||||||
setWall(value){wall=value;},fireAfter(ms){elapsed+=ms;const due=[...timers].filter(([,timer])=>timer.at<=elapsed);for(const[id,timer]of due){timers.delete(id);timer.callback();}return due.map(([,timer])=>timer.callback);}};
|
setWall(value){wall=value;},fireAfter(ms){elapsed+=ms;const due=[...timers].filter(([,timer])=>timer.at<=elapsed);for(const[id,timer]of due){timers.delete(id);timer.callback();}return due.map(([,timer])=>timer.callback);}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 墙上时钟回退时重新计算剩余等待,前跳时只查同一冻结请求;旧回调重复触发不能重复查询或越过依赖。
|
||||||
test('wall-clock jumps recheck retry deadlines without releasing unknown claims or bypassing dependencies',async()=>{
|
test('wall-clock jumps recheck retry deadlines without releasing unknown claims or bypassing dependencies',async()=>{
|
||||||
const clock=adjustableClock(),receipt=deferred(),calls=[];
|
const clock=adjustableClock(),receipt=deferred(),calls=[];
|
||||||
const state=setup({clock:clock.clock,
|
const state=setup({clock:clock.clock,
|
||||||
@@ -255,6 +287,7 @@ test('wall-clock jumps recheck retry deadlines without releasing unknown claims
|
|||||||
for(const callback of staleCallbacks)callback();await state.scheduler.settle();assert.equal(calls.length,4);assert.equal(clock.timers.size,0);assert.equal(state.scheduler.getClaims().size,0);state.scheduler.stop();
|
for(const callback of staleCallbacks)callback();await state.scheduler.settle();assert.equal(calls.length,4);assert.equal(clock.timers.size,0);assert.equal(state.scheduler.getClaims().size,0);state.scheduler.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 分别从回退及前跳的墙上时间恢复,验证最早有效截止时间先查询;恢复过程仍保持原请求体和逻辑 ID。
|
||||||
test('recovered retries recompute remaining wall time and wake the earliest valid request after either clock shift',async()=>{
|
test('recovered retries recompute remaining wall time and wake the earliest valid request after either clock shift',async()=>{
|
||||||
const original=setup({async submit(request){return{outcome:'unresolved',retryAfterMs:request.operationIds[0]==='long'?500:100};}});
|
const original=setup({async submit(request){return{outcome:'unresolved',retryAfterMs:request.operationIds[0]==='long'?500:100};}});
|
||||||
original.add('long',['a']);original.add('short',['b']);original.scheduler.start();await original.scheduler.settle();const saved=original.ledger().getSnapshot();original.scheduler.stop();
|
original.add('long',['a']);original.add('short',['b']);original.scheduler.start();await original.scheduler.settle();const saved=original.ledger().getSnapshot();original.scheduler.stop();
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* selector外部store的缓存与观察者测试。模拟不可变上游快照,核对相等选择复用引用、异常重读及多个订阅独立清理。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||||
|
/** 最小store:set替换state并遍历监听副本,size观察退订;没有真实React挂载或渲染。 */
|
||||||
function source(initial){let state=initial;const listeners=new Set();return{getSnapshot:()=>state,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},set(next){state=next;for(const fn of [...listeners])fn();},get size(){return listeners.size;}};}
|
function source(initial){let state=initial;const listeners=new Set();return{getSnapshot:()=>state,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},set(next){state=next;for(const fn of [...listeners])fn();},get size(){return listeners.size;}};}
|
||||||
|
// selector只取a,自定义比较器忽略新对象引用;改b不通知,改a才发布,重复读取不重复选择计算。
|
||||||
test('selector store caches immutable reads and notifies only changed selections',()=>{const s=source({a:1,b:1});let selections=0,notifications=0;const store=createSelectorStore(s,value=>{selections++;return{a:value.a};},(a,b)=>a.a===b.a),first=store.getSnapshot();assert.strictEqual(store.getSnapshot(),first);assert.equal(selections,1);const stop=store.subscribe(()=>notifications++);s.set({a:1,b:2});assert.strictEqual(store.getSnapshot(),first);assert.equal(notifications,0);s.set({a:2,b:2});assert.equal(notifications,1);assert.equal(store.getSnapshot().a,2);stop();assert.equal(s.size,0);});
|
test('selector store caches immutable reads and notifies only changed selections',()=>{const s=source({a:1,b:1});let selections=0,notifications=0;const store=createSelectorStore(s,value=>{selections++;return{a:value.a};},(a,b)=>a.a===b.a),first=store.getSnapshot();assert.strictEqual(store.getSnapshot(),first);assert.equal(selections,1);const stop=store.subscribe(()=>notifications++);s.set({a:1,b:2});assert.strictEqual(store.getSnapshot(),first);assert.equal(notifications,0);s.set({a:2,b:2});assert.equal(notifications,1);assert.equal(store.getSnapshot().a,2);stop();assert.equal(s.size,0);});
|
||||||
|
// 负值使selector抛错,仍通知读者重读;恢复2再次通知,不能用旧成功值掩盖错误。
|
||||||
test('selector errors notify React to retry the snapshot and retain no invalid cached value',()=>{const s=source({value:1});const store=createSelectorStore(s,next=>{if(next.value<0)throw Error('invalid');return next.value;});let notifications=0;const stop=store.subscribe(()=>notifications++);s.set({value:-1});assert.equal(notifications,1);assert.throws(store.getSnapshot,/invalid/);s.set({value:2});assert.equal(store.getSnapshot(),2);assert.equal(notifications,2);stop();});
|
test('selector errors notify React to retry the snapshot and retain no invalid cached value',()=>{const s=source({value:1});const store=createSelectorStore(s,next=>{if(next.value<0)throw Error('invalid');return next.value;});let notifications=0;const stop=store.subscribe(()=>notifications++);s.set({value:-1});assert.equal(notifications,1);assert.throws(store.getSnapshot,/invalid/);s.set({value:2});assert.equal(store.getSnapshot(),2);assert.equal(notifications,2);stop();});
|
||||||
|
// A在第一次通知中退订,B仍收到两次更新;最后退订上游无监听残留。
|
||||||
test('distinct subscribers observe changes independently and unsubscribe during notification',()=>{const s=source(0),store=createSelectorStore(s,n=>n);let a=0,b=0,stopA;stopA=store.subscribe(()=>{a++;stopA();});const stopB=store.subscribe(()=>b++);s.set(1);s.set(2);assert.equal(a,1);assert.equal(b,2);stopB();assert.equal(s.size,0);});
|
test('distinct subscribers observe changes independently and unsubscribe during notification',()=>{const s=source(0),store=createSelectorStore(s,n=>n);let a=0,b=0,stopA;stopA=store.subscribe(()=>{a++;stopA();});const stopB=store.subscribe(()=>b++);s.set(1);s.set(2);assert.equal(a,1);assert.equal(b,2);stopB();assert.equal(s.size,0);});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 会话按view/元素分桶订阅测试。模拟两视图、mode与多目标gesture,检查一个上游连接下的局部通知及退订生命周期。
|
||||||
|
* 预览可共享给其它视图显示,但active所有权和incarnation必须精确,不因同ID就认领手势。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createSessionSources} from '../../src/lib/canvas-bindings/session-sources.ts';
|
import {createSessionSources} from '../../src/lib/canvas-bindings/session-sources.ts';
|
||||||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||||
|
/** 内存source计数读取与监听,broken模拟订阅失败;emit替换快照,gesture/preview/mode构造最小合法测试变化。 */
|
||||||
function fixture(){let state={views:new Map([['v',{modes:new Map(),viewport:{x:0,y:0,zoom:1}}],['w',{modes:new Map()}]]),gesture:null,connectionGesture:null},reads=0,broken=false;const listeners=new Set();const source={getSnapshot(){reads++;return state;},subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get size(){return listeners.size;},get reads(){return reads;},reset(){reads=0;},broken(value){broken=value;},get:()=>state,emit(next){state=next;for(const fn of [...listeners])fn();},gesture(id='300',viewId='v',incarnation=1){this.emit({...state,gesture:{token:1,viewId,targets:[{id,incarnation}],preview:new Map(),phase:'active'}});},preview(id,x){this.emit({...state,gesture:{...state.gesture,preview:new Map(state.gesture.preview).set(id,{x,y:0,width:10,height:10,rotation:0})}});},mode(view,id,value){const current=state.views.get(view),modes=new Map(current.modes);if(value)modes.set(id,value);else modes.delete(id);this.emit({...state,views:new Map(state.views).set(view,{...current,modes})});}};}
|
function fixture(){let state={views:new Map([['v',{modes:new Map(),viewport:{x:0,y:0,zoom:1}}],['w',{modes:new Map()}]]),gesture:null,connectionGesture:null},reads=0,broken=false;const listeners=new Set();const source={getSnapshot(){reads++;return state;},subscribe(fn){if(broken)throw Error('subscribe failed');listeners.add(fn);return()=>listeners.delete(fn);}};return{source,get size(){return listeners.size;},get reads(){return reads;},reset(){reads=0;},broken(value){broken=value;},get:()=>state,emit(next){state=next;for(const fn of [...listeners])fn();},gesture(id='300',viewId='v',incarnation=1){this.emit({...state,gesture:{token:1,viewId,targets:[{id,incarnation}],preview:new Map(),phase:'active'}});},preview(id,x){this.emit({...state,gesture:{...state.gesture,preview:new Map(state.gesture.preview).set(id,{x,y:0,width:10,height:10,rotation:0})}});},mode(view,id,value){const current=state.views.get(view),modes=new Map(current.modes);if(value)modes.set(id,value);else modes.delete(id);this.emit({...state,views:new Map(state.views).set(view,{...current,modes})});}};}
|
||||||
|
|
||||||
|
// 2000通道共用一个上游,更新300预览只重新选择gesture300;只改viewport不通知这些mode/gesture通道。
|
||||||
test('1000 gesture and mode subscribers share one upstream and only the moved target is evaluated',()=>{
|
test('1000 gesture and mode subscribers share one upstream and only the moved target is evaluated',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),calls=new Map(),notified=new Set(),stops=[];
|
const f=fixture(),sources=createSessionSources(f.source),calls=new Map(),notified=new Set(),stops=[];
|
||||||
for(let index=0;index<1000;index++)for(const kind of ['mode','gesture']){const id=String(index),key=kind+id,channel=kind==='mode'?sources.mode('v',id):sources.gesture('v',{id,incarnation:1}),store=createSelectorStore(channel,value=>{calls.set(key,(calls.get(key)??0)+1);return value;});stops.push(store.subscribe(()=>notified.add(key)));}
|
for(let index=0;index<1000;index++)for(const kind of ['mode','gesture']){const id=String(index),key=kind+id,channel=kind==='mode'?sources.mode('v',id):sources.gesture('v',{id,incarnation:1}),store=createSelectorStore(channel,value=>{calls.set(key,(calls.get(key)??0)+1);return value;});stops.push(store.subscribe(()=>notified.add(key)));}
|
||||||
@@ -10,22 +16,27 @@ test('1000 gesture and mode subscribers share one upstream and only the moved ta
|
|||||||
notified.clear();f.reset();f.emit({...f.get(),views:new Map(f.get().views).set('v',{...f.get().views.get('v'),viewport:{x:50,y:40,zoom:2}})});assert.equal(f.reads,1);assert.equal(notified.size,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
notified.clear();f.reset();f.emit({...f.get(),views:new Map(f.get().views).set('v',{...f.get().views.get('v'),viewport:{x:50,y:40,zoom:2}})});assert.equal(f.reads,1);assert.equal(notified.size,0);for(const stop of stops)stop();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// w:a与v:b分别通知;删除v只清其活动mode,不误清w或唤醒从未有mode的v:a。
|
||||||
test('mode changes route by view and target, and detaching a view resets only its active modes',()=>{
|
test('mode changes route by view and target, and detaching a view resets only its active modes',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.mode('v','a'),b=sources.mode('w','a'),c=sources.mode('v','b'),stops=[a.subscribe(()=>seen.push('v:a')),b.subscribe(()=>seen.push('w:a')),c.subscribe(()=>seen.push('v:b'))];f.mode('w','a','editing');assert.deepEqual(seen,['w:a']);f.mode('v','b','resize');assert.deepEqual(seen,['w:a','v:b']);const views=new Map(f.get().views);views.delete('v');f.emit({...f.get(),views});assert.deepEqual(seen,['w:a','v:b','v:b']);assert.equal(a.getSnapshot(),null);assert.equal(b.getSnapshot(),'editing');for(const stop of stops)stop();
|
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.mode('v','a'),b=sources.mode('w','a'),c=sources.mode('v','b'),stops=[a.subscribe(()=>seen.push('v:a')),b.subscribe(()=>seen.push('w:a')),c.subscribe(()=>seen.push('v:b'))];f.mode('w','a','editing');assert.deepEqual(seen,['w:a']);f.mode('v','b','resize');assert.deepEqual(seen,['w:a','v:b']);const views=new Map(f.get().views);views.delete('v');f.emit({...f.get(),views});assert.deepEqual(seen,['w:a','v:b','v:b']);assert.equal(a.getSnapshot(),null);assert.equal(b.getSnapshot(),'editing');for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// w看到a的预览但active=false,错误incarnation不见预览;gesture结束两者都回到无活动值。
|
||||||
test('preview is shared across views while active ownership and incarnation remain exact',()=>{
|
test('preview is shared across views while active ownership and incarnation remain exact',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),v=sources.gesture('v',{id:'a',incarnation:1}),w=sources.gesture('w',{id:'a',incarnation:1}),stale=sources.gesture('v',{id:'a',incarnation:2}),stops=[v.subscribe(()=>{}),w.subscribe(()=>{}),stale.subscribe(()=>{})];f.gesture('a');f.preview('a',77);assert.equal(v.getSnapshot().active,true);assert.equal(w.getSnapshot().active,false);assert.equal(w.getSnapshot().preview.x,77);assert.deepEqual(stale.getSnapshot(),{preview:undefined,active:false});f.emit({...f.get(),gesture:null});assert.deepEqual(v.getSnapshot(),{preview:undefined,active:false});for(const stop of stops)stop();
|
const f=fixture(),sources=createSessionSources(f.source),v=sources.gesture('v',{id:'a',incarnation:1}),w=sources.gesture('w',{id:'a',incarnation:1}),stale=sources.gesture('v',{id:'a',incarnation:2}),stops=[v.subscribe(()=>{}),w.subscribe(()=>{}),stale.subscribe(()=>{})];f.gesture('a');f.preview('a',77);assert.equal(v.getSnapshot().active,true);assert.equal(w.getSnapshot().active,false);assert.equal(w.getSnapshot().preview.x,77);assert.deepEqual(stale.getSnapshot(),{preview:undefined,active:false});f.emit({...f.get(),gesture:null});assert.deepEqual(v.getSnapshot(),{preview:undefined,active:false});for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同一手势含a/b,逐个更新只通知对应目标;无关connectionGesture变化复用a原快照。
|
||||||
test('multi-target preview only notifies the target whose geometry changed',()=>{
|
test('multi-target preview only notifies the target whose geometry changed',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.gesture('v',{id:'a',incarnation:1}),b=sources.gesture('v',{id:'b',incarnation:1}),stops=[a.subscribe(()=>seen.push('a')),b.subscribe(()=>seen.push('b'))];f.emit({...f.get(),gesture:{token:1,viewId:'v',targets:[{id:'a',incarnation:1},{id:'b',incarnation:1}],preview:new Map(),phase:'active'}});seen.length=0;f.preview('a',5);assert.deepEqual(seen,['a']);f.preview('b',8);assert.deepEqual(seen,['a','b']);const before=a.getSnapshot();f.emit({...f.get(),connectionGesture:{target:{id:'edge',incarnation:1}}});assert.strictEqual(a.getSnapshot(),before);for(const stop of stops)stop();
|
const f=fixture(),sources=createSessionSources(f.source),seen=[],a=sources.gesture('v',{id:'a',incarnation:1}),b=sources.gesture('v',{id:'b',incarnation:1}),stops=[a.subscribe(()=>seen.push('a')),b.subscribe(()=>seen.push('b'))];f.emit({...f.get(),gesture:{token:1,viewId:'v',targets:[{id:'a',incarnation:1},{id:'b',incarnation:1}],preview:new Map(),phase:'active'}});seen.length=0;f.preview('a',5);assert.deepEqual(seen,['a']);f.preview('b',8);assert.deepEqual(seen,['a','b']);const before=a.getSnapshot();f.emit({...f.get(),connectionGesture:{target:{id:'edge',incarnation:1}}});assert.strictEqual(a.getSnapshot(),before);for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 先制造subscribe失败,再让更早监听提前退订当前通道;迟到回调不通知,但直接读取仍最新,重新订阅正常且重复清理安全。
|
||||||
test('unsubscribed reads stay fresh; stale captured upstream callbacks and startup failure clean up safely',()=>{
|
test('unsubscribed reads stay fresh; stale captured upstream callbacks and startup failure clean up safely',()=>{
|
||||||
const f=fixture();let stop;const earlier=f.source.subscribe(()=>stop?.()),sources=createSessionSources(f.source),channel=sources.mode('v','a');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;stop=channel.subscribe(()=>calls++);f.mode('v','a','editing');assert.equal(calls,0);assert.equal(channel.getSnapshot(),'editing');stop();earlier();assert.equal(f.size,0);const again=channel.subscribe(()=>calls++);f.mode('v','a','resize');assert.equal(calls,1);again();again();assert.equal(f.size,0);
|
const f=fixture();let stop;const earlier=f.source.subscribe(()=>stop?.()),sources=createSessionSources(f.source),channel=sources.mode('v','a');f.broken(true);assert.throws(()=>channel.subscribe(()=>{}),/subscribe failed/);f.broken(false);let calls=0;stop=channel.subscribe(()=>calls++);f.mode('v','a','editing');assert.equal(calls,0);assert.equal(channel.getSnapshot(),'editing');stop();earlier();assert.equal(f.size,0);const again=channel.subscribe(()=>calls++);f.mode('v','a','resize');assert.equal(calls,1);again();again();assert.equal(f.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// a回调内读取b必须已更新,即使a抛错b仍被通知,最终才向外抛观察者错误。
|
||||||
test('observer errors do not starve other targets and all values advance before callbacks',()=>{
|
test('observer errors do not starve other targets and all values advance before callbacks',()=>{
|
||||||
const f=fixture(),sources=createSessionSources(f.source),a=sources.mode('v','a'),b=sources.mode('v','b');let calls=0;const stops=[a.subscribe(()=>{assert.equal(b.getSnapshot(),'editing');throw Error('observer failed');}),b.subscribe(()=>calls++)];assert.throws(()=>f.emit({...f.get(),views:new Map(f.get().views).set('v',{modes:new Map([['a','editing'],['b','editing']])})}),/observer failed/);assert.equal(calls,1);for(const stop of stops)stop();
|
const f=fixture(),sources=createSessionSources(f.source),a=sources.mode('v','a'),b=sources.mode('v','b');let calls=0;const stops=[a.subscribe(()=>{assert.equal(b.getSnapshot(),'editing');throw Error('observer failed');}),b.subscribe(()=>calls++)];assert.throws(()=>f.emit({...f.get(),views:new Map(f.get().views).set('v',{modes:new Map([['a','editing'],['b','editing']])})}),/observer failed/);assert.equal(calls,1);for(const stop of stops)stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 结构冲突恢复与历史重建的集成测试。以明确零写入的版本冲突开始,从新的服务端基线重规划保留的意图。
|
||||||
|
* 覆盖分区树/连线共同归属、世界坐标与样式单位、创建删除的身份代次,以及混合已确认/待处理历史的 undo/redo。
|
||||||
|
* 恢复预览先生成私有计划,resolve 才提交;重复恢复还需重放 adoption 证据,不能重发已确认内容或凭空移动历史栈。
|
||||||
|
* fixture 是内存存储和简化服务端,不代表真实浏览器、数据库或完整 Foundry wire 协议测试。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry,defineComponent,movable,resizable,rotatable,unitScale,withUnitScale,describeHistoryTransition } from '../../src/lib/canvas-core/index.ts';
|
import { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry,defineComponent,movable,resizable,rotatable,unitScale,withUnitScale,describeHistoryTransition } from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -6,27 +12,78 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
|||||||
import { replayHistoryEvidence,sameHistory } from '../../src/lib/canvas-runtime/history-replay.ts';
|
import { replayHistoryEvidence,sameHistory } from '../../src/lib/canvas-runtime/history-replay.ts';
|
||||||
import { planConflictRecovery } from '../../src/lib/canvas-runtime/conflicts.ts';
|
import { planConflictRecovery } from '../../src/lib/canvas-runtime/conflicts.ts';
|
||||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||||
|
// 使用内置类型定义和共享基础几何;单个测试另建 editor/engine,避免把状态串到其他场景。
|
||||||
const registry=createRegistry(builtinComponents()),geometry={x:100,y:60,width:100,height:80,rotation:0};
|
const registry=createRegistry(builtinComponents()),geometry={x:100,y:60,width:100,height:80,rotation:0};
|
||||||
|
// 构造第一代文本元素,extra 可覆盖组件、几何及数据,支持 frame 和未来类型样本。
|
||||||
const node=(id,owner='root',extra={})=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry,...extra});
|
const node=(id,owner='root',extra={})=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry,...extra});
|
||||||
|
// 旋转且带 contentScale 的入口,使坐标测试同时涉及平移、旋转和子 canvas 缩放。
|
||||||
const frame=node('f','root',{componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Frame',text:'',presentation:{contentScale:1.5}}});
|
const frame=node('f','root',{componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Frame',text:'',presentation:{contentScale:1.5}}});
|
||||||
|
// 连线用端点引用和 owner 描述所属 canvas;显式 path 与保留字段用来发现重规划时的意外覆盖。
|
||||||
const edge=(id,source,target,owner='root',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,data:{label:'preserve'},source:{elementId:source},target:{elementId:target},path});
|
const edge=(id,source,target,owner='root',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,data:{label:'preserve'},source:{elementId:source},target:{elementId:target},path});
|
||||||
|
// 建立 root/child 两层文档:f 是 child 的入口,a/b 在 root,c 在 child。
|
||||||
const base=()=>createDocument('project',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[frame,node('a'),node('b'),node('c','child')]);
|
const base=()=>createDocument('project',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[frame,node('a'),node('b'),node('c','child')]);
|
||||||
|
// 可转换的贝塞尔链保留扩展字段;比较世界坐标时还要确认未知元数据未丢失。
|
||||||
const path={type:'bezier-chain',extension:{keep:true},knots:[{id:'k',mode:'corner',position:{x:200,y:100},in:{x:-10,y:0},out:{x:10,y:0},extra:'keep'}]};
|
const path={type:'bezier-chain',extension:{keep:true},knots:[{id:'k',mode:'corner',position:{x:200,y:100},in:{x:-10,y:0},out:{x:10,y:0},extra:'keep'}]};
|
||||||
|
// 简写第一代目标身份;恢复出新一代对象时,相关用例会显式给出新的 incarnation。
|
||||||
const target=id=>({id,incarnation:1});
|
const target=id=>({id,incarnation:1});
|
||||||
|
// 挑出尚未解决的非上传操作,用作恢复决策集合;不要把资源上传混入文档重规划。
|
||||||
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
|
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
|
||||||
|
// 默认对所有待恢复文档操作选择 keep-local;个别用例再覆盖为 discard-local。
|
||||||
const choices=engine=>new Map(pending(engine).map(record=>[record.intent.id,'keep-local']));
|
const choices=engine=>new Map(pending(engine).map(record=>[record.intent.id,'keep-local']));
|
||||||
|
// 建立独立的 server、版本表、持久化 image 和 leader;默认所有提交明确 not-applied,便于触发可重规划冲突。
|
||||||
function fixture(original=base()){
|
function fixture(original=base()){
|
||||||
let server=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),image,owner;const calls=[];
|
let server=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),image,owner;const calls=[];
|
||||||
const f={reject:true,calls,storage:{async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},port:{compile(intent,id,snapshot){return{payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const {intent}=JSON.parse(request.body);for(const[id,version]of Object.entries(request.expectedVersions))assert.equal(version,versions.get(id)??0);const after=applyChanges(server,intent.changes);validateDocument(after,registry);for(const change of intent.changes){const ids=change.kind==='canvas'?[change.id]:change.kind==='element'?[change.before?.ownerCanvasId,change.after?.ownerCanvasId]:[server.elements.get(change.target.id)?.ownerCanvasId,after.elements.get(change.target.id)?.ownerCanvasId];for(const id of ids)if(id)assert.ok(request.canvasIds.includes(id));}server=after;for(const id of request.canvasIds)versions.set(id,(versions.get(id)??0)+1);return{outcome:'applied',canonical:{id:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions};},async read(){return{document:server,versions};}},remote(changes){server=applyChanges(server,changes);validateDocument(server,registry);for(const id of new Set([...versions.keys(),...server.canvases.keys()]))versions.set(id,(versions.get(id)??0)+1);},image:()=>structuredClone(image),server:()=>server,async open(extra={}){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,...extra});}};return f;
|
const f={reject:true,calls,storage:{
|
||||||
|
// 同一 fixture 只允许一个活动存储 owner;fence 固定为 1,不模拟多标签竞争或租约续期。
|
||||||
|
async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,
|
||||||
|
// 释放内存 owner,让关闭后可以再次打开引擎。
|
||||||
|
async release(){owner=undefined;}};},
|
||||||
|
// 返回 image 的克隆,模拟重新打开后从持久层读取,避免复用同一内存对象。
|
||||||
|
async recover(){return image&&structuredClone(image);},
|
||||||
|
// 先核对 owner,再克隆并保存整份 image;这里不实现真实事务日志、文件写入或配额。
|
||||||
|
async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},
|
||||||
|
// 这些结构测试不读取资源字节,保留空文件端口满足存储契约。
|
||||||
|
async readFile(){},
|
||||||
|
// 存储无外部句柄;关闭只需由引擎释放 claim,端口 close 无额外动作。
|
||||||
|
close(){}},port:{
|
||||||
|
// 编译单个文档意图;已有 canvas 使用当前基线版本,新 canvas 使用零,payload 直接保存 intent。
|
||||||
|
compile(intent,id,snapshot){return{payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},
|
||||||
|
// 记录每次尝试;默认明确拒绝且零写入。放行时校验版本、应用变化,并检查请求覆盖所有旧/新 owner。
|
||||||
|
async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};
|
||||||
|
// 只有明确放行才解析冻结 body;每个参与 canvas 的 expected version 都必须与假服务端当前值一致。
|
||||||
|
const {intent}=JSON.parse(request.body);for(const[id,version]of Object.entries(request.expectedVersions))assert.equal(version,versions.get(id)??0);
|
||||||
|
// 先构造并校验新文档,再检查作用域覆盖;任何检查失败都发生在替换 server 引用之前。
|
||||||
|
const after=applyChanges(server,intent.changes);validateDocument(after,registry);
|
||||||
|
// canvas 变化需要该 canvas 参与;元素变化需要旧/新 owner,字段变化则从修改前后文档查询 owner。
|
||||||
|
for(const change of intent.changes){const ids=change.kind==='canvas'?[change.id]:change.kind==='element'?[change.before?.ownerCanvasId,change.after?.ownerCanvasId]:[server.elements.get(change.target.id)?.ownerCanvasId,after.elements.get(change.target.id)?.ownerCanvasId];for(const id of ids)if(id)assert.ok(request.canvasIds.includes(id));}
|
||||||
|
// 校验通过才发布 server,并为请求内全部参与 canvas 增加版本;简化回执只携带请求 ID。
|
||||||
|
server=after;for(const id of request.canvasIds)versions.set(id,(versions.get(id)??0)+1);return{outcome:'applied',canonical:{id:request.id}};},
|
||||||
|
// 本 fixture 的结果只有明确成功或失败;若引擎意外进入未知结果查询,立即报错暴露错误路径。
|
||||||
|
async query(){throw Error('unexpected query');},
|
||||||
|
// 成功回执接纳时直接返回当前 server 及版本表;不是从回执反推一份旧文档。
|
||||||
|
canonical(){return{document:server,versions};},
|
||||||
|
// 恢复预览读取此刻 server 基线;remote 修改会影响下一次读取。
|
||||||
|
async read(){return{document:server,versions};}},
|
||||||
|
// 模拟其他客户端:应用并校验文档,再提高所有已知或新增 canvas 的版本,故意让旧请求失配。
|
||||||
|
remote(changes){server=applyChanges(server,changes);validateDocument(server,registry);for(const id of new Set([...versions.keys(),...server.canvases.keys()]))versions.set(id,(versions.get(id)??0)+1);},
|
||||||
|
// 提供克隆的持久化 image 给 decodeRecovery 断言,验证实际保存的数据。
|
||||||
|
image:()=>structuredClone(image),
|
||||||
|
// 只读获取当前服务端文档,供状态和几何比较;用例不能借此替换 server。
|
||||||
|
server:()=>server,
|
||||||
|
// 使用同一作用域及存储打开新引擎;extra 用于测试 historyLimit 等引擎配置。
|
||||||
|
async open(extra={}){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,...extra});}};return f;
|
||||||
}
|
}
|
||||||
|
// 浮点几何比较使用 1e-7 容差,避免旋转/缩放往返产生的舍入差异造成误报。
|
||||||
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
|
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
|
||||||
|
|
||||||
|
// 创建后移动,再收到默认尺寸结果;恢复保留尺寸补偿,但仍只有创建和移动两个撤销单元,redo 创建也使用最终尺寸。
|
||||||
test('pending default size effects recover with preceding creation and movement, preserving one creation undo unit', async () => {
|
test('pending default size effects recover with preceding creation and movement, preserving one creation undo unit', async () => {
|
||||||
const f = fixture(), engine = await f.open();
|
const f = fixture(), engine = await f.open();
|
||||||
try {
|
try {
|
||||||
engine.editor.dispatch({ type: 'element.create', element: node('sized') });
|
engine.editor.dispatch({ type: 'element.create', element: node('sized') });
|
||||||
const entryId = engine.getSnapshot().history.undo.at(-1);
|
const entryId = engine.getSnapshot().history.undo.at(-1);
|
||||||
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
|
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
|
||||||
|
// 尺寸结果回写创建历史,但 rootRevision 仍要匹配当前快照,不能套用到过时草稿。
|
||||||
const root = engine.getSnapshot();
|
const root = engine.getSnapshot();
|
||||||
assert.equal(engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision).status, 'applied');
|
assert.equal(engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision).status, 'applied');
|
||||||
await engine.settle();
|
await engine.settle();
|
||||||
@@ -40,12 +97,14 @@ test('pending default size effects recover with preceding creation and movement,
|
|||||||
} finally { await engine.close(); }
|
} finally { await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 显式放弃尺寸补偿后,创建和移动仍可恢复及撤销;高度保持最初值,不能顺带把整个创建历史删掉。
|
||||||
test('discarding a pending size effect retains the uncorrected creation and movement histories', async () => {
|
test('discarding a pending size effect retains the uncorrected creation and movement histories', async () => {
|
||||||
const f = fixture(), engine = await f.open();
|
const f = fixture(), engine = await f.open();
|
||||||
try {
|
try {
|
||||||
engine.editor.dispatch({ type: 'element.create', element: node('sized') }); const entryId = engine.getSnapshot().history.undo.at(-1);
|
engine.editor.dispatch({ type: 'element.create', element: node('sized') }); const entryId = engine.getSnapshot().history.undo.at(-1);
|
||||||
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
|
engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } });
|
||||||
const root = engine.getSnapshot(), result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision);
|
const root = engine.getSnapshot(), result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision);
|
||||||
|
// 只放弃独立尺寸效果,创建与移动的决策继续保持 keep-local。
|
||||||
await engine.settle(); const selected = choices(engine); selected.set(result.operationIds[0], 'discard-local');
|
await engine.settle(); const selected = choices(engine); selected.set(result.operationIds[0], 'discard-local');
|
||||||
const session = await engine.prepareRecovery(), plan = session.plan(selected);
|
const session = await engine.prepareRecovery(), plan = session.plan(selected);
|
||||||
assert.equal(plan.document.elements.get('sized').geometry.height, 80); assert.equal(plan.history.undo.length, 2);
|
assert.equal(plan.document.elements.get('sized').geometry.height, 80); assert.equal(plan.history.undo.length, 2);
|
||||||
@@ -56,6 +115,7 @@ test('discarding a pending size effect retains the uncorrected creation and move
|
|||||||
} finally { await engine.close(); }
|
} finally { await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 子 canvas 的尺寸结果已确认,而 root 的独立编辑待恢复;重建历史必须保留尺寸事实,之后仍可撤销创建。
|
||||||
test('a confirmed size effect is retained while an earlier independent pending edit is recovered', async () => {
|
test('a confirmed size effect is retained while an earlier independent pending edit is recovered', async () => {
|
||||||
const f = fixture(); f.reject = false; const engine = await f.open();
|
const f = fixture(); f.reject = false; const engine = await f.open();
|
||||||
try {
|
try {
|
||||||
@@ -72,6 +132,7 @@ test('a confirmed size effect is retained while an earlier independent pending e
|
|||||||
} finally { await engine.close(); }
|
} finally { await engine.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 待确认的创建/改名以 absent 基线重新规划;两次 undo/redo 后使用第二代身份,持久化解码也保留该身份。
|
||||||
test('pending create and later edits recover with absent baselines, new keys and lifecycle-safe undo/redo',async()=>{
|
test('pending create and later edits recover with absent baselines, new keys and lifecycle-safe undo/redo',async()=>{
|
||||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
|
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
|
||||||
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||||
@@ -81,6 +142,7 @@ test('pending create and later edits recover with absent baselines, new keys and
|
|||||||
assert.equal(decodeRecovery(f.image(),registry).document.elements.get('new').incarnation,2);await engine.close();
|
assert.equal(decodeRecovery(f.image(),registry).document.elements.get('new').incarnation,2);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 入口、canvas、成员作为一个本地事务一起保留或放弃,不留孤儿;新 canvas 首次版本为零,恢复后仍共用一个撤销单元。
|
||||||
test('an atomic frame/canvas/member creation can be kept or discarded without orphan records',async()=>{
|
test('an atomic frame/canvas/member creation can be kept or discarded without orphan records',async()=>{
|
||||||
for(const keep of [true,false]){
|
for(const keep of [true,false]){
|
||||||
const f=fixture(),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-frame','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'new-canvas',parent:{canvasId:'root',entryElementId:'new-frame'}}});tx.dispatch({type:'element.create',element:node('inside','new-canvas')});});await engine.settle();
|
const f=fixture(),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('new-frame','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'new-canvas',parent:{canvasId:'root',entryElementId:'new-frame'}}});tx.dispatch({type:'element.create',element:node('inside','new-canvas')});});await engine.settle();
|
||||||
@@ -91,6 +153,7 @@ test('an atomic frame/canvas/member creation can be kept or discarded without or
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 删除意图针对新基线重新规划,包含远端新加的关联边;undo 恢复远端新正文及连线,并分配新的元素代次。
|
||||||
test('deletion is re-planned from current content and includes a new remote incident edge in its undo unit',async()=>{
|
test('deletion is re-planned from current content and includes a new remote incident edge in its undo unit',async()=>{
|
||||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();
|
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();
|
||||||
f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote content',beforePresent:true,afterPresent:true},{kind:'element',id:'new-edge',before:null,after:edge('new-edge','a','b')}]);
|
f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote content',beforePresent:true,afterPresent:true},{kind:'element',id:'new-edge',before:null,after:edge('new-edge','a','b')}]);
|
||||||
@@ -98,6 +161,7 @@ test('deletion is re-planned from current content and includes a new remote inci
|
|||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.text,'remote content');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(f.server().elements.get('new-edge').source.elementId,'a');await engine.close();
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.text,'remote content');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(f.server().elements.get('new-edge').source.elementId,'a');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 删除分区以当前子树和跨子 canvas 连线为准,并覆盖相关 canvas 版本;undo 恢复成员身份、入口绑定及端点引用。
|
||||||
test('deleting a partition uses its current subtree and cross-scope edges; undo restores all lifecycle identities',async()=>{
|
test('deleting a partition uses its current subtree and cross-scope edges; undo restores all lifecycle identities',async()=>{
|
||||||
const original=base(),f=fixture(original),engine=await f.open();assert.equal(engine.editor.dispatch({type:'element.remove',target:target('f')}).status,'applied');await engine.settle();
|
const original=base(),f=fixture(original),engine=await f.open();assert.equal(engine.editor.dispatch({type:'element.remove',target:target('f')}).status,'applied');await engine.settle();
|
||||||
f.remote([{kind:'element',id:'remote-child',before:null,after:node('remote-child','child')},{kind:'element',id:'cross',before:null,after:edge('cross','a','remote-child')}]);
|
f.remote([{kind:'element',id:'remote-child',before:null,after:node('remote-child','child')},{kind:'element',id:'cross',before:null,after:edge('cross','a','remote-child')}]);
|
||||||
@@ -105,6 +169,7 @@ test('deleting a partition uses its current subtree and cross-scope edges; undo
|
|||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();validateDocument(f.server(),registry);assert.equal(f.server().elements.get('remote-child').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');assert.equal(f.server().elements.get('cross').target.elementId,'remote-child');await engine.close();
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();validateDocument(f.server(),registry);assert.equal(f.server().elements.get('remote-child').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');assert.equal(f.server().elements.get('cross').target.elementId,'remote-child');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 原入口在远端移动/旋转后,保留本地移动的目标世界位置;连线改归共同 canvas 时曲线换坐标,扩展字段保持。
|
||||||
test('owner moves preserve the intended world position and relocate edge curves with the common canvas',async()=>{
|
test('owner moves preserve the intended world position and relocate edge curves with the common canvas',async()=>{
|
||||||
const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','c','root',path)]),f=fixture(original),engine=await f.open();
|
const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','c','root',path)]),f=fixture(original),engine=await f.open();
|
||||||
const world=toWorld(canvasTransform(original,'root'),{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2});assert.equal(engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).status,'applied');await engine.settle();
|
const world=toWorld(canvasTransform(original,'root'),{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2});assert.equal(engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).status,'applied');await engine.settle();
|
||||||
@@ -114,6 +179,7 @@ test('owner moves preserve the intended world position and relocate edge curves
|
|||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.x,400);assert.equal(f.server().elements.get('e').ownerCanvasId,'root');await engine.close();
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.x,400);assert.equal(f.server().elements.get('e').ownerCanvasId,'root');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 重连以端点当前归属决定连线 owner,并保留远端标签;端点被删除再恢复成新一代时拒绝绑定旧身份。
|
||||||
test('reconnection uses current endpoint scope, preserves unrelated data, and refuses a restored endpoint lifecycle',async()=>{
|
test('reconnection uses current endpoint scope, preserves unrelated data, and refuses a restored endpoint lifecycle',async()=>{
|
||||||
const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','b','root',path)]);
|
const original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),edge('e','a','b','root',path)]);
|
||||||
for(const restored of [false,true]){
|
for(const restored of [false,true]){
|
||||||
@@ -125,18 +191,21 @@ test('reconnection uses current endpoint scope, preserves unrelated data, and re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 放弃创建却保留其改名会缺少目标身份;远端同 ID 创建也不能被冒认成本地对象,计划失败后取消预览。
|
||||||
test('discarded creations cannot be used by kept dependents, and colliding/tombstoned identities never bind foreign content',async()=>{
|
test('discarded creations cannot be used by kept dependents, and colliding/tombstoned identities never bind foreign content',async()=>{
|
||||||
const f=fixture(),engine=await f.open();const create=engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
|
const f=fixture(),engine=await f.open();const create=engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle();
|
||||||
let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(create.operationIds[0],'discard-local');assert.throws(()=>session.plan(selected),/stale_incarnation/);await session.cancel();
|
let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(create.operationIds[0],'discard-local');assert.throws(()=>session.plan(selected),/stale_incarnation/);await session.cancel();
|
||||||
f.remote([{kind:'element',id:'new',before:null,after:node('new')}]);session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);await session.cancel();await engine.close();
|
f.remote([{kind:'element',id:'new',before:null,after:node('new')}]);session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);await session.cancel();await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 核心层独立验证创建/改名的 redo 生命周期,并用 root/child 坐标往返逐字段核对几何。
|
||||||
test('core world/local geometry conversion and history redo preserve user-owned lifecycles',()=>{
|
test('core world/local geometry conversion and history redo preserve user-owned lifecycles',()=>{
|
||||||
const document=base(),editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:node('new')});editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});
|
const document=base(),editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:node('new')});editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});
|
||||||
editor.history.undo();editor.history.undo();assert.equal(editor.history.redo().status,'applied');assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('new').data.title,'edited');
|
editor.history.undo();editor.history.undo();assert.equal(editor.history.redo().status,'applied');assert.equal(editor.history.redo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('new').data.title,'edited');
|
||||||
const from=canvasTransform(document,'root'),to=canvasTransform(document,'child'),converted=convertGeometry(geometry,from,to),round=convertGeometry(converted,to,from);for(const key of ['x','y','width','height','rotation'])near(round[key],geometry[key]);
|
const from=canvasTransform(document,'root'),to=canvasTransform(document,'child'),converted=convertGeometry(geometry,from,to),round=convertGeometry(converted,to,from);for(const key of ['x','y','width','height','rotation'])near(round[key],geometry[key]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远端把旧 child 改绑到 h、另建 child 绑定 f 后,移动 f 只能带走当前新绑定,不能抢回旧 canvas。
|
||||||
test('moving an entry follows its current child binding rather than stealing its former canvas',async()=>{
|
test('moving an entry follows its current child binding rather than stealing its former canvas',async()=>{
|
||||||
const g=node('g','root',{componentId:'frame'}),h=node('h','root',{componentId:'frame'}),original=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,h]),f=fixture(original),engine=await f.open();
|
const g=node('g','root',{componentId:'frame'}),h=node('h','root',{componentId:'frame'}),original=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,h]),f=fixture(original),engine=await f.open();
|
||||||
engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'});await engine.settle();
|
engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'});await engine.settle();
|
||||||
@@ -144,26 +213,33 @@ test('moving an entry follows its current child binding rather than stealing its
|
|||||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.equal(f.server().canvases.get('child').parent.canvasId,'root');assert.equal(f.server().canvases.get('new-child').parent.canvasId,'outer');await engine.close();
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.equal(f.server().canvases.get('child').parent.canvasId,'root');assert.equal(f.server().canvases.get('new-child').parent.canvasId,'outer');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 需变换的未知路径导致命令整体拒绝、快照不变;可处理的容器移动则保持子 canvas 变换且不重写成员。
|
||||||
test('unknown curve formats remain atomic errors while scaled containers preserve their child transform',()=>{
|
test('unknown curve formats remain atomic errors while scaled containers preserve their child transform',()=>{
|
||||||
const g=node('g','root',{componentId:'frame',data:{title:'g',text:'',presentation:{contentScale:2}}}),document=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,edge('e','a','c','root',{type:'future-path',keep:true})]),editor=createEditor({registry,document}),before=editor.getSnapshot();
|
const g=node('g','root',{componentId:'frame',data:{title:'g',text:'',presentation:{contentScale:2}}}),document=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,edge('e','a','c','root',{type:'future-path',keep:true})]),editor=createEditor({registry,document}),before=editor.getSnapshot();
|
||||||
assert.equal(editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).code,'unsupported_coordinate_path');assert.equal(editor.getSnapshot(),before);
|
assert.equal(editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).code,'unsupported_coordinate_path');assert.equal(editor.getSnapshot(),before);
|
||||||
|
// 移动入口补偿它的外层变换,因此子 canvas 的世界变换及成员引用都不需要改写。
|
||||||
const originalTransform=canvasTransform(document,'child');assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'}).status,'applied');const currentTransform=canvasTransform(editor.getSnapshot().document,'child');for(const key of ['x','y','scale','rotation'])near(currentTransform[key],originalTransform[key]);assert.equal(editor.getSnapshot().document.elements.get('c'),before.document.elements.get('c'));
|
const originalTransform=canvasTransform(document,'child');assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'}).status,'applied');const currentTransform=canvasTransform(editor.getSnapshot().document,'child');for(const key of ['x','y','scale','rotation'])near(currentTransform[key],originalTransform[key]);assert.equal(editor.getSnapshot().document.elements.get('c'),before.document.elements.get('c'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// reparent 只改变选定父绑定,同时保留新基线 parent 上的未知字段,不能恢复整个旧 envelope。
|
||||||
test('reparenting preserves fresh unknown parent metadata instead of restoring an older envelope',async()=>{
|
test('reparenting preserves fresh unknown parent metadata instead of restoring an older envelope',async()=>{
|
||||||
const h={...frame,id:'h'},original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),h]),f=fixture(original),engine=await f.open();
|
const h={...frame,id:'h'},original=createDocument('project',[...base().canvases.values()],[...base().elements.values(),h]),f=fixture(original),engine=await f.open();
|
||||||
engine.editor.dispatch({type:'canvas.reparent',canvasId:'child',parent:{canvasId:'root',entryElementId:'h'}});await engine.settle();f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{...original.canvases.get('child'),parent:{...original.canvases.get('child').parent,future:{keep:'remote'}}}}]);
|
engine.editor.dispatch({type:'canvas.reparent',canvasId:'child',parent:{canvasId:'root',entryElementId:'h'}});await engine.settle();f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{...original.canvases.get('child'),parent:{...original.canvases.get('child').parent,future:{keep:'remote'}}}}]);
|
||||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.deepEqual(f.server().canvases.get('child').parent.future,{keep:'remote'});await engine.close();
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.deepEqual(f.server().canvases.get('child').parent.future,{keep:'remote'});await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建多层旋转/缩放分区,混入未来节点、未知路径和 presentation 扩展,验证只改必要外层、不改不透明子内容。
|
||||||
function scaledTree(){
|
function scaledTree(){
|
||||||
const outer=node('g','root',{componentId:'frame',geometry:{x:700,y:250,width:800,height:700,rotation:75},data:{title:'Target',text:'',presentation:{contentScale:2.5}}}),nested=node('nested','child',{componentId:'frame',geometry:{x:20,y:30,width:250,height:180,rotation:340},data:{title:'Nested',text:'',presentation:{contentScale:.8,collapsed:true}}});
|
const outer=node('g','root',{componentId:'frame',geometry:{x:700,y:250,width:800,height:700,rotation:75},data:{title:'Target',text:'',presentation:{contentScale:2.5}}}),nested=node('nested','child',{componentId:'frame',geometry:{x:20,y:30,width:250,height:180,rotation:340},data:{title:'Nested',text:'',presentation:{contentScale:.8,collapsed:true}}});
|
||||||
const original=base(),f={...frame,data:{...frame.data,presentation:{contentScale:.6,future:{keep:'metadata'}}}},inside={...node('inside','child'),data:{title:'Inside',text:'Text',fontSize:19,presentation:{unitScale:.75}}};
|
const original=base(),f={...frame,data:{...frame.data,presentation:{contentScale:.6,future:{keep:'metadata'}}}},inside={...node('inside','child'),data:{title:'Inside',text:'Text',fontSize:19,presentation:{unitScale:.75}}};
|
||||||
return createDocument('project',[...original.canvases.values(),{id:'target',parent:{canvasId:'root',entryElementId:'g'}},{id:'deep',parent:{canvasId:'child',entryElementId:'nested'}}],[...original.elements.values()].filter(element=>element.id!=='f').concat([f,outer,nested,inside,node('future','deep',{componentId:'future-node',dataVersion:9,data:{opaque:{keep:true}}}),node('q','target'),edge('cross','inside','q','root',path),edge('opaque','inside','nested','child',{type:'future-path',payload:{keep:7}})]));
|
return createDocument('project',[...original.canvases.values(),{id:'target',parent:{canvasId:'root',entryElementId:'g'}},{id:'deep',parent:{canvasId:'child',entryElementId:'nested'}}],[...original.elements.values()].filter(element=>element.id!=='f').concat([f,outer,nested,inside,node('future','deep',{componentId:'future-node',dataVersion:9,data:{opaque:{keep:true}}}),node('q','target'),edge('cross','inside','q','root',path),edge('opaque','inside','nested','child',{type:'future-path',payload:{keep:7}})]));
|
||||||
}
|
}
|
||||||
|
// 把节点几何换算到单位世界坐标,并把 canvas 缩放与元素 unitScale 相乘,比较实际视觉尺度。
|
||||||
const worldNode=(document,id)=>{const element=document.elements.get(id),transform=canvasTransform(document,element.ownerCanvasId),g=convertGeometry(element.geometry,transform,{x:0,y:0,scale:1,rotation:0});return{...g,visualScale:transform.scale*((element.data.presentation?.unitScale)??1)};};
|
const worldNode=(document,id)=>{const element=document.elements.get(id),transform=canvasTransform(document,element.ownerCanvasId),g=convertGeometry(element.geometry,transform,{x:0,y:0,scale:1,rotation:0});return{...g,visualScale:transform.scale*((element.data.presentation?.unitScale)??1)};};
|
||||||
|
// 逐字段比较世界几何和视觉尺度,不能只看位置一致就认为跨分区移动保持了外观。
|
||||||
function nearNode(a,b){for(const key of ['x','y','width','height','rotation','visualScale'])near(a[key],b[key]);}
|
function nearNode(a,b){for(const key of ['x','y','width','height','rotation','visualScale'])near(a[key],b[key]);}
|
||||||
|
|
||||||
|
// 带缩放容器跨分区移动后,后代世界几何/视觉单位及 canvas 身份保持;可复用成员引用,undo/redo 保留原 presentation。
|
||||||
test('a scaled container move preserves nested world geometry, visual units, opaque children and all canvas identities',()=>{
|
test('a scaled container move preserves nested world geometry, visual units, opaque children and all canvas identities',()=>{
|
||||||
const original=scaledTree(),editor=createEditor({registry,document:original}),before=editor.getSnapshot();
|
const original=scaledTree(),editor=createEditor({registry,document:original}),before=editor.getSnapshot();
|
||||||
assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'}).status,'applied');const after=editor.getSnapshot().document;
|
assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'}).status,'applied');const after=editor.getSnapshot().document;
|
||||||
@@ -174,6 +250,7 @@ test('a scaled container move preserves nested world geometry, visual units, opa
|
|||||||
assert.equal(editor.history.undo().status,'applied');assert.deepEqual(editor.getSnapshot().document.elements.get('f').data,before.document.elements.get('f').data);assert.equal(editor.history.redo().status,'applied');nearNode(worldNode(editor.getSnapshot().document,'inside'),worldNode(before.document,'inside'));
|
assert.equal(editor.history.undo().status,'applied');assert.deepEqual(editor.getSnapshot().document.elements.get('f').data,before.document.elements.get('f').data);assert.equal(editor.history.redo().status,'applied');nearNode(worldNode(editor.getSnapshot().document,'inside'),worldNode(before.document,'inside'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远端同时改尺寸、文字、折叠、缩放和曲线后再恢复移动;以新内容换单位,不覆盖远端改动,undo 也保留它们。
|
||||||
test('container recovery preserves fresh size, typography, overview, opacity and remote curve edits while changing units',async()=>{
|
test('container recovery preserves fresh size, typography, overview, opacity and remote curve edits while changing units',async()=>{
|
||||||
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'});await engine.settle();const oldFrame=original.elements.get('f'),oldTarget=original.elements.get('g'),remotePath={...path,knots:[{...path.knots[0],position:{x:320,y:170}}]};
|
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'});await engine.settle();const oldFrame=original.elements.get('f'),oldTarget=original.elements.get('g'),remotePath={...path,knots:[{...path.knots[0],position:{x:320,y:170}}]};
|
||||||
f.remote([{kind:'field',target:target('f'),field:'geometry',before:oldFrame.geometry,after:{...oldFrame.geometry,width:700}},{kind:'field',target:target('f'),field:'data.presentation',before:oldFrame.data.presentation,after:{...oldFrame.data.presentation,contentScale:.9,collapsed:true,future:{keep:'fresh'}}},{kind:'field',target:target('g'),field:'data.presentation',before:oldTarget.data.presentation,after:{contentScale:4}},{kind:'field',target:target('inside'),field:'data.fontSize',before:19,after:27,beforePresent:true,afterPresent:true},{kind:'field',target:target('cross'),field:'path',before:path,after:remotePath}]);
|
f.remote([{kind:'field',target:target('f'),field:'geometry',before:oldFrame.geometry,after:{...oldFrame.geometry,width:700}},{kind:'field',target:target('f'),field:'data.presentation',before:oldFrame.data.presentation,after:{...oldFrame.data.presentation,contentScale:.9,collapsed:true,future:{keep:'fresh'}}},{kind:'field',target:target('g'),field:'data.presentation',before:oldTarget.data.presentation,after:{contentScale:4}},{kind:'field',target:target('inside'),field:'data.fontSize',before:19,after:27,beforePresent:true,afterPresent:true},{kind:'field',target:target('cross'),field:'path',before:path,after:remotePath}]);
|
||||||
@@ -184,11 +261,13 @@ test('container recovery preserves fresh size, typography, overview, opacity and
|
|||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.width,700);assert.equal(f.server().elements.get('inside').data.fontSize,27);await engine.close();
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.width,700);assert.equal(f.server().elements.get('inside').data.fontSize,27);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 显式目标几何可移动容器及后代位置,但后代物理尺寸、视觉单位和原 fontSize 不随之缩放。
|
||||||
test('an explicit end position changes the container pose while preserving intrinsic text size and descendant units',()=>{
|
test('an explicit end position changes the container pose while preserving intrinsic text size and descendant units',()=>{
|
||||||
const document=scaledTree(),editor=createEditor({registry,document}),source=document.elements.get('f'),destination=canvasTransform(document,'target'),desired=convertGeometry(source.geometry,canvasTransform(document,'root'),destination);
|
const document=scaledTree(),editor=createEditor({registry,document}),source=document.elements.get('f'),destination=canvasTransform(document,'target'),desired=convertGeometry(source.geometry,canvasTransform(document,'root'),destination);
|
||||||
desired.x+=50;assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'target',geometry:desired}).status,'applied');const after=editor.getSnapshot().document,a=worldNode(document,'inside'),b=worldNode(after,'inside');near(a.width,b.width);near(a.height,b.height);near(a.visualScale,b.visualScale);assert.ok(Math.abs(a.x-b.x)>1);assert.equal(after.elements.get('inside').data.fontSize,19);
|
desired.x+=50;assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'target',geometry:desired}).status,'applied');const after=editor.getSnapshot().document,a=worldNode(document,'inside'),b=worldNode(after,'inside');near(a.width,b.width);near(a.height,b.height);near(a.visualScale,b.visualScale);assert.ok(Math.abs(a.x-b.x)>1);assert.equal(after.elements.get('inside').data.fontSize,19);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 普通节点跨缩放分区保持投影宽度与 viewScale;自定义 resize 下限按视觉单位换算到目标 canvas 的局部宽度。
|
||||||
test('ordinary nodes keep visual style scale across moves, and resize bounds use intrinsic visual units',()=>{
|
test('ordinary nodes keep visual style scale across moves, and resize bounds use intrinsic visual units',()=>{
|
||||||
const document=scaledTree(),editor=createEditor({registry,document}),before=projectCanvas(editor.getSnapshot().document,'root',registry,new Set(),()=>undefined).nodes.find(node=>node.id==='a');
|
const document=scaledTree(),editor=createEditor({registry,document}),before=projectCanvas(editor.getSnapshot().document,'root',registry,new Set(),()=>undefined).nodes.find(node=>node.id==='a');
|
||||||
editor.dispatch({type:'element.move',target:target('a'),canvasId:'target'});const current=editor.getSnapshot().document,after=projectCanvas(current,'root',registry,new Set(),()=>undefined).nodes.find(node=>node.id==='a');
|
editor.dispatch({type:'element.move',target:target('a'),canvasId:'target'});const current=editor.getSnapshot().document,after=projectCanvas(current,'root',registry,new Set(),()=>undefined).nodes.find(node=>node.id==='a');
|
||||||
@@ -197,6 +276,7 @@ test('ordinary nodes keep visual style scale across moves, and resize bounds use
|
|||||||
const boxes=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('box','root',{componentId:'box'})]),box=createEditor({registry:custom,document:boxes});box.dispatch({type:'element.move',target:target('box'),canvasId:'target'});assert.equal(box.dispatch({type:'element.geometry',target:target('box'),patch:{width:1}}).status,'applied');near(box.getSnapshot().document.elements.get('box').geometry.width,32);
|
const boxes=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('box','root',{componentId:'box'})]),box=createEditor({registry:custom,document:boxes});box.dispatch({type:'element.move',target:target('box'),canvasId:'target'});assert.equal(box.dispatch({type:'element.geometry',target:target('box'),patch:{width:1}}).status,'applied');near(box.getSnapshot().document.elements.get('box').geometry.width,32);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 移动后继续折叠的本地编辑应合并新基线属性和换算后 unitScale;逐步撤销也不丢远端扩展。
|
||||||
test('later presentation edits retain rebased units and fresh properties after a pending move',async()=>{
|
test('later presentation edits retain rebased units and fresh properties after a pending move',async()=>{
|
||||||
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'});await engine.settle();const moved=engine.getSnapshot().document.elements.get('f');engine.editor.dispatch({type:'element.data',target:target('f'),patch:{presentation:{...moved.data.presentation,collapsed:true}}});await engine.settle();
|
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'});await engine.settle();const moved=engine.getSnapshot().document.elements.get('f');engine.editor.dispatch({type:'element.data',target:target('f'),patch:{presentation:{...moved.data.presentation,collapsed:true}}});await engine.settle();
|
||||||
const g=original.elements.get('g'),frame=original.elements.get('f');f.remote([{kind:'field',target:target('g'),field:'data.presentation',before:g.data.presentation,after:{contentScale:4}},{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:.9,remoteFlag:'keep'}}]);
|
const g=original.elements.get('g'),frame=original.elements.get('f');f.remote([{kind:'field',target:target('g'),field:'data.presentation',before:g.data.presentation,after:{contentScale:4}},{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:.9,remoteFlag:'keep'}}]);
|
||||||
@@ -204,22 +284,26 @@ test('later presentation edits retain rebased units and fresh properties after a
|
|||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();near(f.server().elements.get('f').data.presentation.unitScale,.25);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').data.presentation.unitScale,undefined);assert.equal(f.server().elements.get('f').data.presentation.remoteFlag,'keep');await engine.close();
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();near(f.server().elements.get('f').data.presentation.unitScale,.25);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').data.presentation.unitScale,undefined);assert.equal(f.server().elements.get('f').data.presentation.remoteFlag,'keep');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 父 contentScale 在远端改变后,待创建节点仍按原意图保留物理几何及字体,通过 unitScale 补偿新的坐标基准。
|
||||||
test('a creation under a changed parent basis retains its intended physical geometry and typography',async()=>{
|
test('a creation under a changed parent basis retains its intended physical geometry and typography',async()=>{
|
||||||
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new','child',{data:{title:'New',text:'Body',fontSize:25}})});await engine.settle();const desired=worldNode(engine.getSnapshot().document,'new'),frame=original.elements.get('f');
|
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new','child',{data:{title:'New',text:'Body',fontSize:25}})});await engine.settle();const desired=worldNode(engine.getSnapshot().document,'new'),frame=original.elements.get('f');
|
||||||
f.remote([{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:1.2}}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();nearNode(worldNode(f.server(),'new'),desired);assert.equal(f.server().elements.get('new').data.fontSize,25);near(f.server().elements.get('new').data.presentation.unitScale,.5);await engine.close();
|
f.remote([{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:1.2}}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();nearNode(worldNode(f.server(),'new'),desired);assert.equal(f.server().elements.get('new').data.fontSize,25);near(f.server().elements.get('new').data.presentation.unitScale,.5);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同一事务显式修改 contentScale 的意图优先于远端同字段,但远端未知属性仍保留,移动单位换算照常完成。
|
||||||
test('explicit presentation changes in a move survive unit conversion without dropping fresh unknown properties',async()=>{
|
test('explicit presentation changes in a move survive unit conversion without dropping fresh unknown properties',async()=>{
|
||||||
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.move',target:target('f'),canvasId:'target'});const moved=tx.getDocument().elements.get('f');tx.dispatch({type:'element.data',target:target('f'),patch:{presentation:{...moved.data.presentation,contentScale:.8,collapsed:false}}});});await engine.settle();const frame=original.elements.get('f');f.remote([{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:.9,remoteFlag:'keep'}}]);
|
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.move',target:target('f'),canvasId:'target'});const moved=tx.getDocument().elements.get('f');tx.dispatch({type:'element.data',target:target('f'),patch:{presentation:{...moved.data.presentation,contentScale:.8,collapsed:false}}});});await engine.settle();const frame=original.elements.get('f');f.remote([{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:.9,remoteFlag:'keep'}}]);
|
||||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').data.presentation.contentScale,.8);assert.equal(f.server().elements.get('f').data.presentation.remoteFlag,'keep');near(f.server().elements.get('f').data.presentation.unitScale,.4);await engine.close();
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').data.presentation.contentScale,.8);assert.equal(f.server().elements.get('f').data.presentation.remoteFlag,'keep');near(f.server().elements.get('f').data.presentation.unitScale,.4);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// unitScale 必须正且有限,非法值拒绝且快照不变;极小合法值不被近似归一,默认单位一可省略。
|
||||||
test('unit metadata has a positive finite schema and tiny valid scales do not disappear during normalization',()=>{
|
test('unit metadata has a positive finite schema and tiny valid scales do not disappear during normalization',()=>{
|
||||||
const editor=createEditor({registry,document:base()}),before=editor.getSnapshot();for(const value of [0,-1,Infinity,NaN,'invalid']){assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{presentation:{unitScale:value}}}).status,'rejected');assert.equal(editor.getSnapshot(),before);}
|
const editor=createEditor({registry,document:base()}),before=editor.getSnapshot();for(const value of [0,-1,Infinity,NaN,'invalid']){assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{presentation:{unitScale:value}}}).status,'rejected');assert.equal(editor.getSnapshot(),before);}
|
||||||
const next=withUnitScale({unitScale:1e-20,future:{keep:true}},1e-30);assert.equal(unitScale(next),1e-30);assert.deepEqual(next.future,{keep:true});assert.equal(withUnitScale({unitScale:.5},1),undefined);
|
const next=withUnitScale({unitScale:1e-20,future:{keep:true}},1e-30);assert.equal(unitScale(next),1e-30);assert.deepEqual(next.future,{keep:true});assert.equal(withUnitScale({unitScale:.5},1),undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 同组已确认创建加待恢复改名,不论保留或放弃改名都保留一个创建历史;undo/redo 不影响远端无关字段。
|
||||||
test('mixed creation history retains its acknowledged prefix when its pending edit is kept or discarded',async()=>{
|
test('mixed creation history retains its acknowledged prefix when its pending edit is kept or discarded',async()=>{
|
||||||
for(const keep of [true,false]){
|
for(const keep of [true,false]){
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;
|
const f=fixture(),engine=await f.open();f.reject=false;
|
||||||
@@ -235,6 +319,7 @@ test('mixed creation history retains its acknowledged prefix when its pending ed
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 入口先确认、canvas 和成员随后待恢复时,同组历史仍是一个撤销单元,重做给入口及成员新身份。
|
||||||
test('mixed acknowledged entry creation and pending canvas/member creation stay one undo unit',async()=>{
|
test('mixed acknowledged entry creation and pending canvas/member creation stay one undo unit',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})},{groupId:'partition'});await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})},{groupId:'partition'});await engine.settle();
|
||||||
f.reject=true;engine.editor.transaction(tx=>{tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});},{groupId:'partition'});await engine.settle();
|
f.reject=true;engine.editor.transaction(tx=>{tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});},{groupId:'partition'});await engine.settle();
|
||||||
@@ -243,6 +328,7 @@ test('mixed acknowledged entry creation and pending canvas/member creation stay
|
|||||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('entry').incarnation,2);assert.equal(f.server().elements.get('member').incarnation,2);await engine.close();
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('entry').incarnation,2);assert.equal(f.server().elements.get('member').incarnation,2);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 已确认改名加待恢复删除应一起撤销;恢复出的新一代对象回到改名前标题,同时保留远端新正文。
|
||||||
test('mixed acknowledged edit and pending deletion undo against fresh data without losing the prefix',async()=>{
|
test('mixed acknowledged edit and pending deletion undo against fresh data without losing the prefix',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'remove'});await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'remove'});await engine.settle();
|
||||||
f.reject=true;engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'remove'});await engine.settle();
|
f.reject=true;engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'remove'});await engine.settle();
|
||||||
@@ -252,6 +338,7 @@ test('mixed acknowledged edit and pending deletion undo against fresh data witho
|
|||||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),false);await engine.close();
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),false);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同组先确认删除、后恢复兄弟编辑;undo 恢复新一代被删对象并撤销兄弟编辑,redo 重新删除及编辑。
|
||||||
test('an acknowledged deletion prefix can restore a new lifecycle after recovery of a pending sibling edit',async()=>{
|
test('an acknowledged deletion prefix can restore a new lifecycle after recovery of a pending sibling edit',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'mixed-delete'});await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'mixed-delete'});await engine.settle();
|
||||||
f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}},{groupId:'mixed-delete'});await engine.settle();
|
f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}},{groupId:'mixed-delete'});await engine.settle();
|
||||||
@@ -260,6 +347,7 @@ test('an acknowledged deletion prefix can restore a new lifecycle after recovery
|
|||||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),false);assert.equal(f.server().elements.get('b').data.title,'pending');await engine.close();
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),false);assert.equal(f.server().elements.get('b').data.title,'pending');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同组已确认 owner 移动与待恢复改名一起撤销/重做,但远端正文不属于该历史贡献,必须保留。
|
||||||
test('mixed owner movement and a pending edit preserve fresh unrelated fields through undo and redo',async()=>{
|
test('mixed owner movement and a pending edit preserve fresh unrelated fields through undo and redo',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'},{groupId:'move'});await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'},{groupId:'move'});await engine.settle();
|
||||||
f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'move'});await engine.settle();
|
f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'move'});await engine.settle();
|
||||||
@@ -269,6 +357,7 @@ test('mixed owner movement and a pending edit preserve fresh unrelated fields th
|
|||||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'child');assert.equal(f.server().elements.get('a').data.title,'pending');await engine.close();
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'child');assert.equal(f.server().elements.get('a').data.title,'pending');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 已确认结构前缀若出现远端新成员或新 incarnation,不能静默生成会删除外来内容的逆操作;失败保持快照不变。
|
||||||
test('mixed structural history cannot silently delete remote additions or claim a newer lifecycle',async()=>{
|
test('mixed structural history cannot silently delete remote additions or claim a newer lifecycle',async()=>{
|
||||||
for(const change of ['remote-member','new-life']){
|
for(const change of ['remote-member','new-life']){
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})},{groupId:'conflict'});await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})},{groupId:'conflict'});await engine.settle();
|
||||||
@@ -280,6 +369,7 @@ test('mixed structural history cannot silently delete remote additions or claim
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 先拒绝删除 a、再确认同组的 c 编辑;前者保留或放弃都不重发后者,并维持一个可撤销分组及正确身份。
|
||||||
test('a later confirmed group operation retains its fact while a rejected structural predecessor is kept or discarded',async()=>{
|
test('a later confirmed group operation retains its fact while a rejected structural predecessor is kept or discarded',async()=>{
|
||||||
for(const keep of [true,false]){
|
for(const keep of [true,false]){
|
||||||
const f=fixture(),submit=f.port.submit;let deny=true;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=deny&&intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);};
|
const f=fixture(),submit=f.port.submit;let deny=true;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=deny&&intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);};
|
||||||
@@ -290,6 +380,7 @@ test('a later confirmed group operation retains its fact while a rejected struct
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 分别对被拒的 undo/redo 选择保留或放弃,核对服务端内容、两栈位置和原操作 superseded/discarded 状态,保留无关远端正文。
|
||||||
test('rejected undo and redo can be explicitly kept or discarded with the correct history stack',async()=>{
|
test('rejected undo and redo can be explicitly kept or discarded with the correct history stack',async()=>{
|
||||||
for(const direction of ['undo','redo'])for(const keep of [true,false]){
|
for(const direction of ['undo','redo'])for(const keep of [true,false]){
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();
|
||||||
@@ -303,23 +394,27 @@ test('rejected undo and redo can be explicitly kept or discarded with the correc
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 保留被拒 undo 时逆操作基于已审阅的远端标题生成;之后 redo 应回到远端值,而非过时的 edited。
|
||||||
test('a kept rejected undo uses the reviewed remote value as its redo baseline',async()=>{
|
test('a kept rejected undo uses the reviewed remote value as its redo baseline',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
||||||
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');
|
||||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');await engine.close();
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 已确认删除分区的 undo 被拒后,只能恢复下一代入口和成员;后续再删再恢复则递增到第三代。
|
||||||
test('a rejected undo of a confirmed partition deletion restores only the proved next lifecycle',async()=>{
|
test('a rejected undo of a confirmed partition deletion restores only the proved next lifecycle',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('f')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('f')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
||||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').incarnation,2);assert.equal(f.server().elements.get('c').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').incarnation,2);assert.equal(f.server().elements.get('c').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f');
|
||||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('child'),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').incarnation,3);await engine.close();
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('child'),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').incarnation,3);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 已确认创建又撤销后,恢复被拒 redo 需沿用归档的 canvas 版本而非误当全新 canvas,成员身份递增。
|
||||||
test('a rejected redo after confirmed undo can restore creation with the archived canvas version',async()=>{
|
test('a rejected redo after confirmed undo can restore creation with the archived canvas version',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});});await engine.settle();engine.editor.history.undo();await engine.settle();f.reject=true;engine.editor.history.redo();await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});});await engine.settle();engine.editor.history.undo();await engine.settle();f.reject=true;engine.editor.history.redo();await engine.settle();
|
||||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').incarnation,2);assert.ok(f.calls.at(-1).expectedVersions.inside>0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('inside'),false);await engine.close();
|
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').incarnation,2);assert.ok(f.calls.at(-1).expectedVersions.inside>0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('inside'),false);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 连续两个被拒 undo 不能跳过栈顶 traversal 而只保留后一个;全部保留后按正确顺序重建 redo 栈。
|
||||||
test('multiple rejected undos retain stack order and cannot skip a discarded top traversal',async()=>{
|
test('multiple rejected undos retain stack order and cannot skip a discarded top traversal',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.undo();await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.undo();await engine.settle();
|
||||||
const ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map([[ids[0],'discard-local'],[ids[1],'keep-local']])),/recovery_history_traversal_order/);assert.equal(engine.getSnapshot(),before);
|
const ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map([[ids[0],'discard-local'],[ids[1],'keep-local']])),/recovery_history_traversal_order/);assert.equal(engine.getSnapshot(),before);
|
||||||
@@ -327,22 +422,26 @@ test('multiple rejected undos retain stack order and cannot skip a discarded top
|
|||||||
engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'second');await engine.close();
|
engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'second');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远端已使用并删除下一代身份时,本地旧恢复证明不能认领更高墓碑;计划拒绝且原历史保持。
|
||||||
test('a failed restoration cannot claim a remotely advanced tombstone and leaves its original history intact',async()=>{
|
test('a failed restoration cannot claim a remotely advanced tombstone and leaves its original history intact',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
||||||
f.remote([{kind:'element',id:'a',before:null,after:{...node('a'),incarnation:2}},{kind:'element',id:'a',before:{...node('a'),incarnation:2},after:null}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
f.remote([{kind:'element',id:'a',before:null,after:{...node('a'),incarnation:2}},{kind:'element',id:'a',before:{...node('a'),incarnation:2},after:null}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 远端已实现所保留 undo 的目标值时,只解决其空贡献并记录 satisfied 审计,不生成新操作或残留空历史。
|
||||||
test('an already satisfied kept undo retires only its empty contribution with an audit',async()=>{
|
test('an already satisfied kept undo retires only its empty contribution with an audit',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();const original=pending(engine)[0].intent;
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();const original=pending(engine)[0].intent;
|
||||||
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'a',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.equal(plan.history.entries.size,0);assert.equal(plan.operationIds.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);assert.equal(engine.getSnapshot().runtime.ledger.operations.get(original.id).resolution,'satisfied');await engine.close();
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'a',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.equal(plan.history.entries.size,0);assert.equal(plan.operationIds.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);assert.equal(engine.getSnapshot().runtime.ledger.operations.get(original.id).resolution,'satisfied');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 放弃被拒 traversal 也不能把旧逆操作扩张到远端冲突值;无法安全重建历史时拒绝而不发布。
|
||||||
test('discarding a rejected traversal never broadens its old inverse over a remote field conflict',async()=>{
|
test('discarding a rejected traversal never broadens its old inverse over a remote field conflict',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();
|
||||||
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local']))),/recovery_history_traversal_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local']))),/recovery_history_traversal_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 混合编辑、undo/redo 循环及 undo 后新分支分别重放历史证据,检查清空 redo 和后续可撤销行为符合原事件顺序。
|
||||||
test('history evidence recovers mixed edits, repeated traversal and a cleared redo branch',async()=>{
|
test('history evidence recovers mixed edits, repeated traversal and a cleared redo branch',async()=>{
|
||||||
for(const mode of ['mixed','repeat','orphan']){
|
for(const mode of ['mixed','repeat','orphan']){
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;
|
||||||
@@ -358,26 +457,31 @@ test('history evidence recovers mixed edits, repeated traversal and a cleared re
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 先 undo 再 redo 的待恢复链在新远端基线上重建,最终及之后的 redo 都使用审阅过的远端值。
|
||||||
test('repeated undo-redo uses the reviewed remote baseline instead of resurrecting the old after value',async()=>{
|
test('repeated undo-redo uses the reviewed remote baseline instead of resurrecting the old after value',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.redo();await engine.settle();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.redo();await engine.settle();
|
||||||
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');await engine.close();
|
f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 放弃待恢复 undo 但保留其后的新分支,应留下原编辑加分支两个合法 undo 条目,之后按栈顺序撤销。
|
||||||
test('discarding a pending undo then keeping a new branch preserves the original edit and a legal two-entry undo stack',async()=>{
|
test('discarding a pending undo then keeping a new branch preserves the original edit and a legal two-entry undo stack',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');assert.equal(f.server().elements.get('b').data.title,'branch');assert.equal(engine.getSnapshot().history.undo.length,2);
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');assert.equal(f.server().elements.get('b').data.title,'branch');assert.equal(engine.getSnapshot().history.undo.length,2);
|
||||||
engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');await engine.close();
|
engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建、撤销、重做及第二代改名一起恢复后保留身份依赖;再次完整 undo/redo 才进入第三代,不能复用旧身份。
|
||||||
test('pending create-undo-redo and later edits preserve generations and all history dependencies',async()=>{
|
test('pending create-undo-redo and later edits preserve generations and all history dependencies',async()=>{
|
||||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});engine.editor.history.undo();engine.editor.history.redo();engine.editor.dispatch({type:'element.data',target:{id:'new',incarnation:2},patch:{title:'edited second life'}});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,'edited second life');
|
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});engine.editor.history.undo();engine.editor.history.redo();engine.editor.dispatch({type:'element.data',target:{id:'new',incarnation:2},patch:{title:'edited second life'}});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,'edited second life');
|
||||||
for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().elements.has('new'),false);for(let i=0;i<2;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('new').incarnation,3);assert.equal(f.server().elements.get('new').data.title,'edited second life');await engine.close();
|
for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().elements.has('new'),false);for(let i=0;i<2;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('new').incarnation,3);assert.equal(f.server().elements.get('new').data.title,'edited second life');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// history:false 的编辑携带显式不移动栈的证据;恢复其正文修改时不能把先前 undo 形成的 redo 分支清空。
|
||||||
test('history-disabled user edits carry explicit no-op evidence and do not clear a recovered redo stack',async()=>{
|
test('history-disabled user edits carry explicit no-op evidence and do not clear a recovered redo stack',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();const change=engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'untracked'}},{history:false});await engine.settle();assert.equal(engine.getSnapshot().operations.get(change.operationIds[0]).historyTransition.entryId,undefined);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('b').data.title,'untracked');assert.equal(engine.getSnapshot().history.redo.length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');await engine.close();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();const change=engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'untracked'}},{history:false});await engine.settle();assert.equal(engine.getSnapshot().operations.get(change.operationIds[0]).historyTransition.entryId,undefined);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('b').data.title,'untracked');assert.equal(engine.getSnapshot().history.redo.length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 后来的新分支已经确认时,仅重放它的历史事件;前序 undo 保留或放弃都不能再次写入分支正文。
|
||||||
test('a later confirmed branch is replayed as a history event without applying its content twice',async()=>{
|
test('a later confirmed branch is replayed as a history event without applying its content twice',async()=>{
|
||||||
for(const keep of [true,false]){
|
for(const keep of [true,false]){
|
||||||
const f=fixture(),engine=await f.open();f.port.verifyApplied=(request,receipt)=>{assert.equal(receipt.id,request.id);const versions=new Map(request.canvasIds.map(id=>[id,request.expectedVersions[id]+1]));return{versions,assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);assert.equal(proposal.document.elements.get('c').data.title,'confirmed branch');}};};
|
const f=fixture(),engine=await f.open();f.port.verifyApplied=(request,receipt)=>{assert.equal(receipt.id,request.id);const versions=new Map(request.canvasIds.map(id=>[id,request.expectedVersions[id]+1]));return{versions,assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);assert.equal(proposal.document.elements.get('c').data.title,'confirmed branch');}};};
|
||||||
@@ -387,6 +491,7 @@ test('a later confirmed branch is replayed as a history event without applying i
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 分别伪造未来、缺失及未证明的旧式 historyTransition,恢复必须明确拒绝且不能发布到实时状态。
|
||||||
test('native event recovery refuses future, missing or unproved legacy transitions without publishing',async()=>{
|
test('native event recovery refuses future, missing or unproved legacy transitions without publishing',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.redo();await engine.settle();const source=engine.getSnapshot(),canonical=await f.port.read(),selected=choices(engine);
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.redo();await engine.settle();const source=engine.getSnapshot(),canonical=await f.port.read(),selected=choices(engine);
|
||||||
for(const mode of ['future','missing','legacy']){
|
for(const mode of ['future','missing','legacy']){
|
||||||
@@ -397,11 +502,13 @@ test('native event recovery refuses future, missing or unproved legacy transitio
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 引擎 historyLimit=2 时,放弃 undo 会多保留旧项;恢复仍按原容量淘汰最早条目,不能临时扩大历史。
|
||||||
test('history event replay uses the engine capacity when discarding undo retains an extra prior entry',async()=>{
|
test('history event replay uses the engine capacity when discarding undo retains an extra prior entry',async()=>{
|
||||||
const f=fixture(),engine=await f.open({historyLimit:2});f.reject=false;const first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);assert.equal(plan.history.undo.length,2);assert.equal(plan.history.entries.has(first.historyEntryId),false);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.editor.history.undo().code,'history_empty');await engine.close();
|
const f=fixture(),engine=await f.open({historyLimit:2});f.reject=false;const first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);assert.equal(plan.history.undo.length,2);assert.equal(plan.history.entries.has(first.historyEntryId),false);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.editor.history.undo().code,'history_empty');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 同组第一步可保留、放弃或已满足,再组合第二步保留/放弃,检查分组诞生点、空组清理及 undo 基线重建。
|
||||||
test('grouped user edits rebuild after discarding or satisfying the original entry birth',async()=>{
|
test('grouped user edits rebuild after discarding or satisfying the original entry birth',async()=>{
|
||||||
for(const firstChoice of ['keep-local','discard-local','satisfied'])for(const secondKeep of [true,false]){
|
for(const firstChoice of ['keep-local','discard-local','satisfied'])for(const secondKeep of [true,false]){
|
||||||
const f=fixture(),engine=await f.open(),first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'typing'});await engine.settle();const second=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'typing'});await engine.settle();if(firstChoice==='satisfied')f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'one',beforePresent:true,afterPresent:true}]);
|
const f=fixture(),engine=await f.open(),first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'typing'});await engine.settle();const second=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'typing'});await engine.settle();if(firstChoice==='satisfied')f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'one',beforePresent:true,afterPresent:true}]);
|
||||||
@@ -410,18 +517,29 @@ test('grouped user edits rebuild after discarding or satisfying the original ent
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 放弃分组后缀时,只剪掉已被远端覆盖的标题历史贡献;仍保留正文的可撤销贡献,undo 不覆盖远端标题。
|
||||||
test('discarding a group suffix removes only the acknowledged field contribution already overwritten remotely',async()=>{
|
test('discarding a group suffix removes only the acknowledged field contribution already overwritten remotely',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack',text:'keep history'}},{groupId:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]);
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack',text:'keep history'}},{groupId:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]);
|
||||||
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');assert.equal(f.server().elements.get('a').data.text,'');await engine.close();
|
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');assert.equal(f.server().elements.get('a').data.text,'');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 历史几何前缀的 owner 已在远端改变时,旧坐标没有安全新基准,必须报告前缀冲突并保持实时快照。
|
||||||
test('a historical geometry prefix cannot silently use coordinates from an externally changed owner',async()=>{
|
test('a historical geometry prefix cannot silently use coordinates from an externally changed owner',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.geometry',target:target('a'),patch:{width:200}},{groupId:'geometry'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'geometry'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'ownerCanvasId',before:'root',after:'child'}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_prefix_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.geometry',target:target('a'),patch:{width:200}},{groupId:'geometry'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'geometry'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'ownerCanvasId',before:'root',after:'child'}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_prefix_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function proveFixtureReceipt(f){f.port.verifyApplied=(request,receipt)=>{assert.equal(receipt.id,request.id);const intent=JSON.parse(request.body).intent,versions=new Map(request.canvasIds.map(id=>[id,request.expectedVersions[id]+1]));return{versions,assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);for(const change of intent.changes){if(change.kind==='element'&&change.after)assert.deepEqual(proposal.document.elements.get(change.id),change.after);else if(change.kind==='field'){const record=proposal.document.elements.get(change.target.id),field=change.field.startsWith('data.')?change.field.slice(5):undefined;assert.deepEqual(field===undefined?record[change.field]:record.data[field]??null,change.after);}}}};};}
|
// 为需要乱序确认的场景安装简化成功证明:验证请求 ID、递增版本和已创建/修改值,允许只重放历史而不重写内容。
|
||||||
|
// 该证明辅助器不覆盖元素删除、缺失字段与所有 wire 类型;仅用于下面确认过的 traversal/字段场景。
|
||||||
|
function proveFixtureReceipt(f){
|
||||||
|
// 回执解释入口校验请求身份,再依据该请求的期望版本建立各 canvas 的成功版本下界。
|
||||||
|
f.port.verifyApplied=(request,receipt)=>{assert.equal(receipt.id,request.id);const intent=JSON.parse(request.body).intent,versions=new Map(request.canvasIds.map(id=>[id,request.expectedVersions[id]+1]));return{versions,
|
||||||
|
// 恢复读取可以更新,但每个版本不能早于回执;本辅助器检查创建对象和字段 after 值。
|
||||||
|
assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);
|
||||||
|
// 创建比较完整元素;字段比较区分 data 子字段和元素顶层字段,缺失 data 值按本 fixture 的 null 约定比较。
|
||||||
|
for(const change of intent.changes){if(change.kind==='element'&&change.after)assert.deepEqual(proposal.document.elements.get(change.id),change.after);else if(change.kind==='field'){const record=proposal.document.elements.get(change.target.id),field=change.field.startsWith('data.')?change.field.slice(5):undefined;assert.deepEqual(field===undefined?record[change.field]:record.data[field]??null,change.after);}}}};};}
|
||||||
|
|
||||||
|
// 先拒绝一个 traversal、再确认后续 undo/redo:恢复不能跳过前者,后者仅重放历史、不得再次发写请求。
|
||||||
test('a later confirmed undo or redo changes only history during recovery and cannot skip a discarded earlier traversal',async()=>{
|
test('a later confirmed undo or redo changes only history during recovery and cannot skip a discarded earlier traversal',async()=>{
|
||||||
for(const direction of ['undo','redo']){
|
for(const direction of ['undo','redo']){
|
||||||
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited a'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'edited c'}});await engine.settle();
|
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited a'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'edited c'}});await engine.settle();
|
||||||
@@ -432,11 +550,13 @@ test('a later confirmed undo or redo changes only history during recovery and ca
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 后续已确认 undo 把对象恢复到第二代时,旧历史需映射到新身份,但旧 operations 的第一代证据不能被篡改。
|
||||||
test('a later confirmed undo restoration remaps older history without changing old operation incarnations',async()=>{
|
test('a later confirmed undo restoration remaps older history without changing old operation incarnations',async()=>{
|
||||||
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;const old=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'edited c'}});await engine.settle();f.reject=true;const pendingUndo=engine.editor.history.undo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('a').incarnation,2);
|
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;const old=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'edited c'}});await engine.settle();f.reject=true;const pendingUndo=engine.editor.history.undo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('a').incarnation,2);
|
||||||
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[pendingUndo.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().operations.get(old.operationIds[0]).changes[0].target.incarnation,1);assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');await engine.close();
|
const session=await engine.prepareRecovery(),plan=session.plan(new Map([[pendingUndo.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().operations.get(old.operationIds[0]).changes[0].target.incarnation,1);assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 已确认 redo 创建第二代对象后,剩余 redo 的字段目标需随之映射;再完整遍历历史会创建第三代且内容保持。
|
||||||
test('a later confirmed redo restoration remaps a remaining redo entry into its applied lifecycle',async()=>{
|
test('a later confirmed redo restoration remaps a remaining redo entry into its applied lifecycle',async()=>{
|
||||||
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited a'}});await engine.settle();engine.editor.dispatch({type:'element.create',element:node('new','child')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited new'}});await engine.settle();for(let i=0;i<3;i++){engine.editor.history.undo();await engine.settle();}
|
const f=fixture();proveFixtureReceipt(f);const engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited a'}});await engine.settle();engine.editor.dispatch({type:'element.create',element:node('new','child')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited new'}});await engine.settle();for(let i=0;i<3;i++){engine.editor.history.undo();await engine.settle();}
|
||||||
f.reject=true;const first=engine.editor.history.redo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('new').data.title,'edited new');
|
f.reject=true;const first=engine.editor.history.redo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('new').data.title,'edited new');
|
||||||
@@ -444,13 +564,16 @@ test('a later confirmed redo restoration remaps a remaining redo entry into its
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 远端覆盖导致的历史前缀剪除需作为 adjustment 保存在 adoption 证明中,重放证据应精确得到计划历史并可持久化解码。
|
||||||
test('settled history adjustments in an adoption proof preserve the explicit removal of a remote-overwritten prefix',async()=>{
|
test('settled history adjustments in an adoption proof preserve the explicit removal of a remote-overwritten prefix',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]);const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));assert.equal(plan.history.entries.size,0);assert.ok(plan.audit.historyReplay.events.some(event=>event.kind==='adjustment'));assert.ok(sameHistory(replayHistoryEvidence(before.history,plan.audit.historyReplay),plan.history));await session.resolve(plan);assert.equal(decodeRecovery(f.image(),registry).history.entries.size,0);await engine.close();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]);const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));assert.equal(plan.history.entries.size,0);assert.ok(plan.audit.historyReplay.events.some(event=>event.kind==='adjustment'));assert.ok(sameHistory(replayHistoryEvidence(before.history,plan.audit.historyReplay),plan.history));await session.resolve(plan);assert.equal(decodeRecovery(f.image(),registry).history.entries.size,0);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 连续三次恢复同组替代操作,再加入原生编辑;每轮从最新审阅基线重规划,旧冻结请求体不变,undo 回到最新远端值。
|
||||||
test('repeated adoption re-plans replacement groups plus a later native edit from the newest reviewed baseline',async()=>{
|
test('repeated adoption re-plans replacement groups plus a later native edit from the newest reviewed baseline',async()=>{
|
||||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'chain'});engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'chain'});await engine.settle();const bodies=new Map();
|
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'chain'});engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'chain'});await engine.settle();const bodies=new Map();
|
||||||
|
// 前两轮继续让新替代请求冲突,第三轮放行,迫使证明链跨过多个 adoption 和新原生编辑。
|
||||||
for(let round=0;round<3;round++){
|
for(let round=0;round<3;round++){
|
||||||
if(round)f.remote([{kind:'field',target:target('a'),field:'data.title',before:round===1?'a':'remote 1',after:'remote '+round,beforePresent:true,afterPresent:true}]);
|
if(round)f.remote([{kind:'field',target:target('a'),field:'data.title',before:round===1?'a':'remote 1',after:'remote '+round,beforePresent:true,afterPresent:true}]);
|
||||||
if(round===2){engine.editor.dispatch({type:'element.data',target:target('a'),patch:{text:'native after adoption'}},{groupId:'chain'});await engine.settle();}
|
if(round===2){engine.editor.dispatch({type:'element.data',target:target('a'),patch:{text:'native after adoption'}},{groupId:'chain'});await engine.settle();}
|
||||||
@@ -459,22 +582,27 @@ test('repeated adoption re-plans replacement groups plus a later native edit fro
|
|||||||
assert.equal(f.server().elements.get('a').data.title,'two');assert.equal(f.server().elements.get('a').data.text,'native after adoption');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote 2');assert.equal(f.server().elements.get('a').data.text,'');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'two');await engine.close();
|
assert.equal(f.server().elements.get('a').data.title,'two');assert.equal(f.server().elements.get('a').data.text,'native after adoption');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote 2');assert.equal(f.server().elements.get('a').data.text,'');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'two');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 先恢复结构创建,再编辑其中成员并再次恢复;跨两次 adoption 后仍能整体撤销/重做结构,并保留远端无关改名。
|
||||||
test('a structural replacement and later native member edit recover across adoption with lifecycle-safe undo/redo',async()=>{
|
test('a structural replacement and later native member edit recover across adoption with lifecycle-safe undo/redo',async()=>{
|
||||||
const f=fixture(),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});});await engine.settle();let session=await engine.prepareRecovery();await session.resolve(session.plan(choices(engine)));await engine.settle();engine.editor.dispatch({type:'element.data',target:target('member'),patch:{title:'edited after adoption'}});await engine.settle();f.remote([{kind:'field',target:target('b'),field:'data.title',before:'b',after:'remote b',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').data.title,'edited after adoption');for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().canvases.has('inside'),false);for(let i=0;i<2;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('member').incarnation,2);assert.equal(f.server().elements.get('member').data.title,'edited after adoption');assert.equal(f.server().elements.get('b').data.title,'remote b');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,2);await engine.close();
|
const f=fixture(),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});});await engine.settle();let session=await engine.prepareRecovery();await session.resolve(session.plan(choices(engine)));await engine.settle();engine.editor.dispatch({type:'element.data',target:target('member'),patch:{title:'edited after adoption'}});await engine.settle();f.remote([{kind:'field',target:target('b'),field:'data.title',before:'b',after:'remote b',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').data.title,'edited after adoption');for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().canvases.has('inside'),false);for(let i=0;i<2;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('member').incarnation,2);assert.equal(f.server().elements.get('member').data.title,'edited after adoption');assert.equal(f.server().elements.get('b').data.title,'remote b');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,2);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// undo/redo 的替代操作再次恢复时,使用最新远端基线;后续 redo 不能复活第一次恢复时的旧值。
|
||||||
test('undo-redo replacements recover across an adoption without resurrecting a stale remote value',async()=>{
|
test('undo-redo replacements recover across an adoption without resurrecting a stale remote value',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.redo();await engine.settle();let session=await engine.prepareRecovery();await session.resolve(session.plan(choices(engine)));await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'reviewed remote',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'reviewed remote');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'reviewed remote');await engine.close();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.redo();await engine.settle();let session=await engine.prepareRecovery();await session.resolve(session.plan(choices(engine)));await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'reviewed remote',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'reviewed remote');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'reviewed remote');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 替代分组前缀已经确认、兄弟项仍冲突时,再次恢复只重规划兄弟项;确认前缀发送一次,undo 使用新远端基线。
|
||||||
test('an applied replacement prefix is reconstructed from the adoption once while its sibling is replanned',async()=>{
|
test('an applied replacement prefix is reconstructed from the adoption once while its sibling is replanned',async()=>{
|
||||||
const f=fixture(),submit=f.port.submit;let mode='all';f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=mode==='all'||mode==='child'&&intent.changes.some(change=>change.kind==='field'&&change.target.id==='c');return submit(request);};const engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}},{groupId:'shared'});engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'second'}},{groupId:'shared'});await engine.settle();let session=await engine.prepareRecovery(),firstPlan=session.plan(choices(engine));mode='child';await session.resolve(firstPlan);await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.operations.get(firstPlan.operationIds[0]).resolution,'applied');f.remote([{kind:'field',target:target('c'),field:'data.title',before:'c',after:'remote c',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const secondPlan=session.plan(choices(engine));mode='none';await session.resolve(secondPlan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===firstPlan.operationIds[0]).length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('c').data.title,'remote c');await engine.close();
|
const f=fixture(),submit=f.port.submit;let mode='all';f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=mode==='all'||mode==='child'&&intent.changes.some(change=>change.kind==='field'&&change.target.id==='c');return submit(request);};const engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}},{groupId:'shared'});engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'second'}},{groupId:'shared'});await engine.settle();let session=await engine.prepareRecovery(),firstPlan=session.plan(choices(engine));mode='child';await session.resolve(firstPlan);await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.operations.get(firstPlan.operationIds[0]).resolution,'applied');f.remote([{kind:'field',target:target('c'),field:'data.title',before:'c',after:'remote c',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const secondPlan=session.plan(choices(engine));mode='none';await session.resolve(secondPlan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===firstPlan.operationIds[0]).length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('c').data.title,'remote c');await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 第一次恢复形成的已结算剪枝证据,在第二次放弃替代创建及其 undo 后仍可安全重放;不增加写请求或留下空历史。
|
||||||
test('settled pruning is replayed safely when earlier replacement creation and its undo are both discarded',async()=>{
|
test('settled pruning is replayed safely when earlier replacement creation and its undo are both discarded',async()=>{
|
||||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'a'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'b pending'}});engine.editor.history.undo();const suffix=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'a pending'}},{groupId:'a'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote a',beforePresent:true,afterPresent:true}]);let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(suffix.operationIds[0],'discard-local');const first=session.plan(selected);assert.ok(first.audit.historyReplay.events.some(event=>event.kind==='adjustment'));await session.resolve(first);await engine.settle();const count=f.calls.length;session=await engine.prepareRecovery();const plan=session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local'])));await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,count);assert.equal(engine.getSnapshot().history.entries.size,0);assert.equal(f.server().elements.get('a').data.title,'remote a');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,2);await engine.close();
|
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'a'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'b pending'}});engine.editor.history.undo();const suffix=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'a pending'}},{groupId:'a'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote a',beforePresent:true,afterPresent:true}]);let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(suffix.operationIds[0],'discard-local');const first=session.plan(selected);assert.ok(first.audit.historyReplay.events.some(event=>event.kind==='adjustment'));await session.resolve(first);await engine.settle();const count=f.calls.length;session=await engine.prepareRecovery();const plan=session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local'])));await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,count);assert.equal(engine.getSnapshot().history.entries.size,0);assert.equal(f.server().elements.get('a').data.title,'remote a');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,2);await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 编辑及其 undo 全部放弃,或已确认编辑后的被拒 undo/redo 全部放弃,都不应捏造一次栈移动;两栈按原事实保留。
|
||||||
test('discarding both an edit and its undo, or both a rejected undo and redo, does not invent a stack move',async()=>{
|
test('discarding both an edit and its undo, or both a rejected undo and redo, does not invent a stack move',async()=>{
|
||||||
for(const confirmed of [false,true]){
|
for(const confirmed of [false,true]){
|
||||||
const f=fixture(),engine=await f.open();if(confirmed)f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();if(confirmed)engine.editor.history.redo();await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local'])));await session.resolve(plan);assert.equal(engine.getSnapshot().history.redo.length,0);assert.equal(engine.getSnapshot().history.undo.length,confirmed?1:0);assert.equal(f.server().elements.get('a').data.title,confirmed?'edited':'a');await engine.close();
|
const f=fixture(),engine=await f.open();if(confirmed)f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();if(confirmed)engine.editor.history.redo();await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local'])));await session.resolve(plan);assert.equal(engine.getSnapshot().history.redo.length,0);assert.equal(engine.getSnapshot().history.undo.length,confirmed?1:0);assert.equal(f.server().elements.get('a').data.title,confirmed?'edited':'a');await engine.close();
|
||||||
@@ -482,8 +610,10 @@ test('discarding both an edit and its undo, or both a rejected undo and redo, do
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 篡改 adoption 重放证据为缺失、未来版本、错误 anchor 或缺少后续原生转换时,计划拒绝且实时快照不变。
|
||||||
test('adoption replay refuses missing, future, incomplete or divergent evidence without changing live state',async()=>{
|
test('adoption replay refuses missing, future, incomplete or divergent evidence without changing live state',async()=>{
|
||||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}});await engine.settle();const first=await engine.prepareRecovery(),plan=first.plan(choices(engine));await first.resolve(plan);await engine.settle();const native=engine.editor.history.undo();await engine.settle();const original=engine.getSnapshot(),canonical=await f.port.read(),selected=choices(engine);
|
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}});await engine.settle();const first=await engine.prepareRecovery(),plan=first.plan(choices(engine));await first.resolve(plan);await engine.settle();const native=engine.editor.history.undo();await engine.settle();const original=engine.getSnapshot(),canonical=await f.port.read(),selected=choices(engine);
|
||||||
|
// 只改私有复制的证据表,直接调用规划器;每个非法变体都必须拒绝且不影响运行中的 engine。
|
||||||
for(const mode of ['missing','future','macro-future','anchor','native-missing']){
|
for(const mode of ['missing','future','macro-future','anchor','native-missing']){
|
||||||
const audit=structuredClone(original.runtime.recoveries.get(plan.id)),recoveries=new Map(original.runtime.recoveries),operations=new Map(original.operations),records=new Map(original.runtime.ledger.operations);
|
const audit=structuredClone(original.runtime.recoveries.get(plan.id)),recoveries=new Map(original.runtime.recoveries),operations=new Map(original.operations),records=new Map(original.runtime.ledger.operations);
|
||||||
if(mode==='missing')delete audit.historyReplay;else if(mode==='future')audit.historyReplay={version:2,opaque:'keep'};else if(mode==='macro-future')audit.historyTransition={version:2,opaque:'keep'};else if(mode==='anchor')audit.historyReplay.anchor.before.undo.push('unrelated');
|
if(mode==='missing')delete audit.historyReplay;else if(mode==='future')audit.historyReplay={version:2,opaque:'keep'};else if(mode==='macro-future')audit.historyTransition={version:2,opaque:'keep'};else if(mode==='anchor')audit.historyReplay.anchor.before.undo.push('unrelated');
|
||||||
@@ -494,6 +624,7 @@ test('adoption replay refuses missing, future, incomplete or divergent evidence
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 给 adoption 操作伪造额外原生 no-op 事件,会重复解释同一操作的历史作用,必须按事件顺序冲突拒绝。
|
||||||
test('an adoption operation cannot also masquerade as a later native no-op event',async()=>{
|
test('an adoption operation cannot also masquerade as a later native no-op event',async()=>{
|
||||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{history:false});await engine.settle();const first=await engine.prepareRecovery(),plan=first.plan(choices(engine));await first.resolve(plan);await engine.settle();const source=engine.getSnapshot(),id=plan.operationIds[0],transition=describeHistoryTransition(source.history,source.history,source.rootRevision,undefined,true),operations=new Map(source.operations),records=new Map(source.runtime.ledger.operations),intent={...records.get(id).intent,historyTransition:transition};operations.set(id,{...operations.get(id),historyTransition:transition});records.set(id,{...records.get(id),intent});const forged={...source,operations,runtime:{...source.runtime,ledger:{...source.runtime.ledger,operations:records}}},awaitedCanonical=await f.port.read();assert.throws(()=>planConflictRecovery(forged,awaitedCanonical,choices(engine),registry,()=>crypto.randomUUID()),/recovery_history_event_order/);
|
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{history:false});await engine.settle();const first=await engine.prepareRecovery(),plan=first.plan(choices(engine));await first.resolve(plan);await engine.settle();const source=engine.getSnapshot(),id=plan.operationIds[0],transition=describeHistoryTransition(source.history,source.history,source.rootRevision,undefined,true),operations=new Map(source.operations),records=new Map(source.runtime.ledger.operations),intent={...records.get(id).intent,historyTransition:transition};operations.set(id,{...operations.get(id),historyTransition:transition});records.set(id,{...records.get(id),intent});const forged={...source,operations,runtime:{...source.runtime,ledger:{...source.runtime.ledger,operations:records}}},awaitedCanonical=await f.port.read();assert.throws(()=>planConflictRecovery(forged,awaitedCanonical,choices(engine),registry,()=>crypto.randomUUID()),/recovery_history_event_order/);
|
||||||
await engine.close();
|
await engine.close();
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 原生矩形可见判断的边界回归。用于决定是否还需要额外保留,不代替paint范围、焦点/手势保留或完整场景裁剪。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {nativeRectVisible} from '../../src/lib/canvas-bindings/visibility.ts';
|
import {test} from 'node:test';import assert from 'node:assert/strict';import {nativeRectVisible} from '../../src/lib/canvas-bindings/visibility.ts';
|
||||||
|
// 检查内含、部分重叠、恰好右边界及完全外部;零宽几何保守返回可见,不能把未知尺寸直接裁掉。
|
||||||
test('additional retention is unnecessary for native-visible nodes and required for offscreen targets',()=>{const viewport={x:0,y:0,width:100,height:100};assert.equal(nativeRectVisible({x:10,y:10,width:20,height:20},viewport),true);assert.equal(nativeRectVisible({x:99,y:99,width:20,height:20},viewport),true);assert.equal(nativeRectVisible({x:100,y:0,width:10,height:10},viewport),false);assert.equal(nativeRectVisible({x:-20,y:-20,width:10,height:10},viewport),false);assert.equal(nativeRectVisible({x:1000,y:1000,width:0,height:10},viewport),true);});
|
test('additional retention is unnecessary for native-visible nodes and required for offscreen targets',()=>{const viewport={x:0,y:0,width:100,height:100};assert.equal(nativeRectVisible({x:10,y:10,width:20,height:20},viewport),true);assert.equal(nativeRectVisible({x:99,y:99,width:20,height:20},viewport),true);assert.equal(nativeRectVisible({x:100,y:0,width:10,height:10},viewport),false);assert.equal(nativeRectVisible({x:-20,y:-20,width:10,height:10},viewport),false);assert.equal(nativeRectVisible({x:1000,y:1000,width:0,height:10},viewport),true);});
|
||||||
|
|||||||
Reference in New Issue
Block a user