Compare commits

..

26 Commits

Author SHA1 Message Date
codex-bot ff6719f6c0 docs(tests): explain browser drivers React fixtures and renderer probes 2026-11-12 11:11:20 +08:00
codex-bot 9d788e1712 docs(tests): explain service contracts proxy and packaged integration consumers 2026-11-12 11:01:48 +08:00
codex-bot bd4f1a34a0 docs(tests): explain engine recovery cuts conflict plans and lifecycle guards 2026-11-12 10:53:33 +08:00
codex-bot dd36d4f795 docs(tests): explain AI run admission recovery controls and output ownership 2026-11-12 10:44:54 +08:00
codex-bot 9ae7d2d56a docs(tests): explain legacy migration gates receipts and UI recovery 2026-11-12 10:36:36 +08:00
codex-bot bad68c6d59 docs(tests): explain partial model editing and loading protocols 2026-11-12 10:32:05 +08:00
codex-bot a1e3a32b61 docs(tests): explain model streams previews and scoped read caches 2026-11-12 10:26:33 +08:00
codex-bot 7b0e734e16 docs(tests): explain engine import publication recovery and source seals 2026-11-12 10:20:30 +08:00
codex-bot d5ab402592 docs(tests): explain progressive import parsers proofs and runner 2026-11-12 10:14:58 +08:00
codex-bot 3714af788f docs(tests): explain deep copy sources checkpoints and input probe 2026-11-12 10:09:17 +08:00
codex-bot 2f55f59bfc docs(tests): explain worker pools hashes and copy budgets 2026-11-12 10:03:55 +08:00
Zayden-Jung dc0f25cad0 Merge pull request 'docs(tests): 说明绘制、同步、恢复与资源测试' (#8) from codex/paint-test-comments into main
Reviewed-on: #8
2026-11-12 02:01:13 +00:00
codex-bot 7de9a3ea1d docs(tests): explain project resource lifecycle and collection 2026-11-12 10:00:05 +08:00
codex-bot 3cda36b685 docs(tests): explain asset adoption retention and depot storage 2026-11-12 09:56:11 +08:00
codex-bot 1a0d9cdc8a docs(tests): explain structural recovery and history adoption 2026-11-12 09:50:31 +08:00
codex-bot 53905a4868 docs(tests): explain recovery encoding journals and applied receipts 2026-11-12 09:45:10 +08:00
codex-bot 298f4b36c0 docs(tests): explain ledger scheduling replay and local elision 2026-11-12 09:41:49 +08:00
codex-bot c426e94b58 docs(tests): explain retained views resource handles and batch proofs 2026-11-12 02:43:36 +08:00
codex-bot 330d8d6e34 docs(tests): explain paint bounds hit queries and scene caching 2026-11-12 02:40:06 +08:00
Zayden-Jung ea102b9949 Merge pull request 'docs(tests): 说明核心、几何、输入与订阅测试' (#7) from codex/core-test-comments into main
Reviewed-on: #7
2026-11-11 18:39:28 +00:00
Zayden-Jung efcec4d603 Merge pull request 'docs(tooling): 说明构建脚本、配置与 SDK 宿主示例' (#6) from codex/build-script-comments into main
Reviewed-on: #6
2026-11-11 18:39:22 +00:00
Zayden-Jung e75b9ed5e4 Merge pull request 'docs(canvas): 说明新旧主画布视图与交互适配' (#5) from codex/headless-view-comments into main
Reviewed-on: #5
2026-11-11 18:39:10 +00:00
codex-bot 74748d6e23 docs(tests): explain input routing and subscription lifecycle coverage 2026-11-12 02:36:36 +08:00
codex-bot 3da19e288a docs(tests): explain query geometry and creation history scenarios 2026-11-12 02:33:00 +08:00
codex-bot 47e318682e docs(tests): explain core invariants and history evidence scenarios 2026-11-12 02:29:37 +08:00
codex-bot 9946142d5e docs(tooling): explain SDK packaging and icon build configuration 2026-11-12 02:25:44 +08:00
114 changed files with 2452 additions and 80 deletions
+31
View File
@@ -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.
* 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.
@@ -9,15 +14,24 @@ import { openProjectEngine } from '@endoleap/headless-canvas/runtime';
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
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'};
// 内置组件之外注册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:[]}})]);
// 空项目只有根canvas;server/version模拟服务端基线,image模拟本地恢复镜像,两者职责分开。
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
let image=null,leader,fence=0,server=original,version=1;
// files保存示例字节,requests按requestId保存原body和回执,供相同请求重试查询;均在进程结束后丢失。
const files=new Map(),requests=new Map();
/** 通过对象身份及active校验本地leader句柄;仅演示单进程fence,不是数据库条件写。 */
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
// StoragePort示例:claim取得独占句柄,recover读克隆镜像,commit先准备镜像/文件再一次同步发布。
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;},
/** 返回镜像克隆,调用方不能直接修改存储事实;空image代表首次打开。 */
async recover(handle){check(handle);return structuredClone(image);},
/** 先检查文件新增不覆盖和删除列表,准备完成才发布;返回实际镜像rootRevision作为本地确认。 */
async commit(handle,batch){
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);}
@@ -25,11 +39,15 @@ const 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};
},
/** 文件读取也校验leader;close断言所有engine已释放领导权,不负责清理生产存储。 */
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:[]});
/** 最小协议2传输适配:模型GET、请求回执GET及操作POST;未实现任意Foundry端点或HTTP鉴权。 */
const transport=async input=>{
input.signal?.throwIfAborted();
// 请求身份查询没命中时返回unknown,不伪造确定未应用回执;本示例记录不跨进程保存。
if(input.method==='GET'){
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
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);
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};}
// 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。
assert.deepEqual(body.expected_versions,{[scope.projectId]: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}};
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});
/** 新建engine注入scope/registry/storage/persistence与ID生成器,所有宿主依赖显式传入。 */
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
let engine=await open();
try{
// 先创建一个counter并settle,后续断言分别验证本地发布及模拟server状态;元素带独立incarnation。
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}};
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
// 非法事务先移动再写负count,最终必须整体rejected且根对象不变,不能泄露事务中间结果。
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);
// 合法事务将移动和增量组合成一个历史单元;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.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);
// 临时手势的有效几何可变化,但cancel后文档仍x=32且请求数不增加,证明预览未被当持久编辑发送。
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);
// 深复制走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 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();
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 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));
// 输出可机器读取的结果摘要;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}));
}finally{await engine.close();storage.close();}
+9
View File
@@ -1,6 +1,12 @@
/**
* Next应用配置。为webpack与Turbopack安装相同ReactFlow编译扩展,并给带内容摘要的图标分片设置长期缓存。
* 此处只配置应用编译/响应头,不改变headless SDK导出或服务端数据协议。
*/
import type { NextConfig } from "next";
/** StrictMode显式开启开发检查,poweredByHeader关闭Next标识响应头;其余保持框架默认行为。 */
const config: NextConfig = {
reactStrictMode: true, poweredByHeader: false,
/** 在现有规则后追加匹配ReactFlow ESM入口的loader,再返回原配置对象,保留框架其它规则。 */
webpack(config) {
config.module.rules.push({
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
@@ -8,6 +14,7 @@ const config: NextConfig = {
});
return config;
},
// Turbopack使用条件路径匹配同一个入口;单独配置以免切换构建器时丢失保留节点扩展。
turbopack: {
rules: {
"*": {
@@ -16,6 +23,8 @@ const config: NextConfig = {
},
},
},
/** 只给版本目录内带12位十六进制摘要的JSON分片配置一年immutable缓存,文件内容变化应生成新文件名。
* 不对图标目录manifest、页面或全部JSON一概设置immutable。 */
async headers() {
return [{
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
+4
View File
@@ -1 +1,5 @@
/**
* CSS构建入口:启用项目安装的Tailwind PostCSS插件,供Next样式处理链读取。
* 插件选项为空对象表示使用默认配置;这里不执行CSS生成,也不在导入时启动构建。
*/
export default { plugins: { "@tailwindcss/postcss": {} } };
+11
View File
@@ -1,11 +1,19 @@
/**
* 独立headless SDK的产物生成脚本。先清理.headless-dist,再用tsconfig.headless.json编译四个库目录,修正声明与Worker引用。
* 最后复制包清单、无UI宿主示例和验收文档;失败立即抛出,不把半成品报告成可用包。执行会删除并重建该产物目录。
*/
import { execFileSync } from 'node:child_process';
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join, resolve } from 'node:path';
// 路径基于脚本URL而非当前终端目录;tsc仍显式以仓库根作为cwd解析项目依赖。
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
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'});
/** 递归只处理.d.ts的相对模块说明符,将源.ts后缀改为.js;普通schema/data字符串不做全局替换。
* 这是针对当前输出格式的正则修补,不是任意TypeScript语法的完整重写器。 */
async function declarations(directory){
for(const entry of await readdir(directory,{withFileTypes:true})){
const path=join(directory,entry.name);
@@ -19,12 +27,15 @@ async function declarations(directory){
}
}
}
// 原生编译器处理运行时import后缀,声明文件仍需此补充;完成后再修URL字面量里的Worker源后缀。
await declarations(join(output,'modules'));
// 两个浏览器适配器各应含一个预期Worker URL;要求目标.js文件存在并且源字符串只出现一次才改写。
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`;
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
await readFile(join(output,'modules/canvas-runtime',`${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));
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
+5
View File
@@ -1,3 +1,7 @@
/**
* 图标资源生成时使用的中文标注表。作为静态元数据输入给generate-canvas-icons,不在运行时逐个请求翻译。
*/
/** 完整PascalCase图标名到中文标签的人工覆盖表;未列出的图标由生成脚本回退slug拆词名称。 */
export const chinese = {
"Star": "星星",
"Heart": "爱心",
@@ -45,4 +49,5 @@ export const chinese = {
"Triangle": "三角形",
"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: "灯泡 灵感" };
+18
View File
@@ -1,3 +1,7 @@
/**
* 重建画布图标目录与路径分片的离线资源脚本。使用已安装Lucide与指定本地Tabler包,写目录JSON、摘要文件名分片和许可证。
* 版本检查固定输入格式;不会联网下载包,也不删除旧摘要分片,调用者需按脚本注释准备输入并从仓库根执行。
*/
// 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
import { readFile, writeFile, mkdir } from "node:fs/promises";
@@ -6,13 +10,16 @@ import { resolve } from "node:path";
import dynamicIconImports from "lucide-react/dynamicIconImports";
import { chinese, keywords } from "./canvas-icon-labels.mjs";
// 命令行参数是已解压Tabler包目录;同时校验两套依赖版本,防止升级后未经审查改变SVG/导出结构。
const tablerDir = process.argv[2];
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"));
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"));
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; };
/** 用slug正则派生分类,一个图标可匹配多个;未匹配的记录categories为空,UI可归“其他”。 */
const categories = [
["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/],
["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/],
["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"]);
// Map以唯一name聚合元数据和SVG节点,Lucide加载器的__iconData提供路径,尚未在此写最终分片。
const entries = new Map();
for (const [slug, load] of Object.entries(dynamicIconImports)) {
const name = pascal(slug);
const { __iconData } = await load();
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 tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"];
for (const [index, slug] of tabler.entries()) {
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([
["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);
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 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; };
// 目录去掉SVG node,仅保留检索元数据;路径放入分片,Lucide分96桶,Tabler集中一个桶。
const catalog = records.map(({ node, ...entry }) => {
const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`;
if (!shards.has(key)) shards.set(key, {});
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(" ") };
});
// 输出目录带两套版本,manifest把分片逻辑key映射到真实文件名;不在运行时猜hash路径。
const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
await mkdir(assetDir, { recursive: true });
let totalBytes = 0;
const assets = {};
// 每个分片用紧凑JSON计算SHA-256前12位作文件名,写文件额外加换行;totalBytes统计未加换行的内容字节。
for (const [key, nodes] of shards) {
const content = JSON.stringify(nodes);
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");
totalBytes += Buffer.byteLength(content);
}
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
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(`${assetDir}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/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`);
+10
View File
@@ -1,15 +1,25 @@
/**
* 应用编译期的ReactFlow可见性扩展。为视口外但仍有焦点/手势/浮层的显式保留对象扩展原生可见集合。
* 只转换匹配到的编译输入字符串,不修改共享node_modules文件,也不进入独立headless SDK的运行逻辑。
*/
// 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.
// 读取安装包版本,固定12.12.1;库升级必须人工审查目标实现,不能凭相同函数名继续替换。
const version = require('@xyflow/react/package.json').version;
/** webpack/Turbopack loader接收源码并返回改写后的源码;任何版本或锚点不符立即失败,防止悄悄失去保留能力。 */
module.exports = function retainVisibility(source) {
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 edges = 'isEdgeVisible({\n sourceNode,';
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
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) {
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
}
// 节点追加未hidden且显式保留的ID并Set去重;边以保留标记或原可见判断二选一,维持原生索引。
// 最后一处改为空数组不订阅完整选择集合,减少无选择监听时的不必要全图selector工作。
return source
.replace(nodes, `Array.from(new Set([
...${nodes},
+21
View File
@@ -1,3 +1,8 @@
/**
* 服务端已证明 applied、但本地尚未完成 canonical 接纳时的恢复测试。
* 模拟 codec 暂不可用、新远端编辑、持久化失败及回执过期,验证恢复不会重发已成功操作或提前释放 writer。
* 这里使用内存传输和克隆快照存储,覆盖协议协调,不覆盖真实数据库或浏览器存储实现。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
@@ -5,25 +10,37 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
// 固定 UUID 作用域与元素身份,满足 Foundry adapter 的请求验证要求。
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
// 文本元素及其第一代身份,远端几何修改和本地命令都以这一身份为条件。
const target={id:'a',incarnation:1},element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}};
// 内置定义与初始文档;fixture 用新的 server 引用演进,保留原始文档作为恢复对照。
const registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[element]);
// 构造可注入 codec、配额和过旧读取故障的服务端/存储环境,记录全部请求用于检查重复写入。
function fixture(){
let server=original,version=1,image,leader;const calls=[],receipts=new Map();
// 把指定文档和版本封装成服务响应;complete=false 表示回执附带的局部模型,而非完整恢复读取。
const model=(document=server,currentVersion=version,complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:{[root]:currentVersion},removedCanvasIds:[]});
// 存储 claim 检查单 leader;commit 在克隆完整 image 前检查配额及 owner,remoteGeometry 则模拟其他客户端提交。
const f={failCodec:true,broken:false,behind:false,calls,receipts,storage:{async claim(scope){assert.equal(leader,undefined);return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},image:()=>structuredClone(image),remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;}};
// GET 分别提供模型与回执;POST 先检查 expected_versions,再应用操作、增加版本并保存可查询回执。
const transport=async request=>{
calls.push(request);if(request.method==='GET'){if(request.path.endsWith('/model'))return{status:200,body:{model:f.behind?model(original,1):model()}};const requestId=request.path.split('/').at(-1),receipt=receipts.get(requestId);return receipt?{status:200,body:receipt}:{status:404,body:{protocol_version:2,request_id:requestId,status:'unknown'}};}
// 此假服务只支持本用例的顺序写入:按请求内操作应用 changes,并以同一 requestId 记录成功证据。
const input=JSON.parse(request.body);assert.equal(input.expected_versions[root],version);for(const operation of input.operations)server=applyChanges(server,operation.changes);version++;
const receipt={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:input.operations.map(operation=>operation.id),model:model(server,version,false)};receipts.set(request.requestId,receipt);return{status:200,body:receipt};
};
const base=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
// 只让 canonical 接纳暂时失败;网络提交仍成功,从而停在 applied 但 phase=waiting 的待协调状态。
f.port={...base,canonical(...args){if(f.failCodec)throw Error('codec_temporarily_unavailable');return base.canonical(...args);}};
// 以同一作用域及存储重新打开引擎,使关闭后的恢复确实读取上次保存的 image。
f.open=()=>openProjectEngine({scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage:f.storage,persistence:f.port});
return f;
}
// 先提交一次改名并确认网络成功、本地接纳等待;后续各用例都从这个关键中间状态开始。
async function applied(f){const engine=await f.open();engine.editor.dispatch({type:'element.data',target,patch:{title:'first'}});await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');return{engine,request};}
// 远端又修改几何后,恢复以较新的完整读取接纳已成功请求;保留原历史与冻结字节,重开后也不增加 POST。
test('a verified applied request adopts a newer full read without reapplying its old receipt or generating a new write',async()=>{
const f=fixture(),{engine,request}=await applied(f);f.remoteGeometry({x:500,width:300});const history=engine.getSnapshot().history,session=await engine.prepareRecovery();
assert.deepEqual(session.acknowledgedRequestIds,[request.id]);assert.deepEqual(session.pendingOperationIds,[]);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');
@@ -32,6 +49,7 @@ test('a verified applied request adopts a newer full read without reapplying its
await engine.close();const recovered=await f.open();await recovered.settle();assert.equal(recovered.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await recovered.close();
});
// 把成功确认和显式 keep-local 重规划一起持久化;配额失败不能派发后续写,重试后撤销仍保留远端的新宽度。
test('acknowledgement and explicit replan commit together; original writer releases only after the new cut is durable',async()=>{
const f=fixture(),{engine,request}=await applied(f);const edit=engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}});await engine.settle();f.remoteGeometry({x:500,width:300});
const session=await engine.prepareRecovery();assert.ok(session.conflict);assert.deepEqual(session.pendingOperationIds,edit.operationIds);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));
@@ -42,11 +60,13 @@ test('acknowledgement and explicit replan commit together; original writer relea
assert.equal(decodeRecovery(f.image(),registry).runtime.ledger.requests.get(request.id).body,request.body);await engine.close();
});
// 取消恢复预览应保持整个原快照、waiting 请求和历史不变,不产生第二次 POST。
test('a cancelled acknowledgement preview leaves the applied request pending and its original history untouched',async()=>{
const f=fixture(),{engine,request}=await applied(f),before=engine.getSnapshot(),session=await engine.prepareRecovery();await session.cancel();
assert.equal(engine.getSnapshot(),before);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await engine.close();
});
// 完整读取落后于成功回执时拒绝接纳;回执覆盖错误和缺失在再次读模型前拒绝,不能靠新写入消除疑点。
test('a fresh read behind a proven receipt cannot release its claim; malformed and missing proofs remain preserved',async()=>{
const f=fixture(),{engine,request}=await applied(f);f.behind=true;await assert.rejects(engine.prepareRecovery(),/recovery_read_behind_receipt/);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');
f.behind=false;const beforeReads=f.calls.filter(call=>call.path.endsWith('/model')).length;
@@ -54,6 +74,7 @@ test('a fresh read behind a proven receipt cannot release its claim; malformed a
assert.equal(f.calls.filter(call=>call.path.endsWith('/model')).length,beforeReads);engine.editor.updateRuntime(runtime=>({...runtime,receipts:new Map()}));await assert.rejects(engine.prepareRecovery(),/recovery_receipt_missing/);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await engine.close();
});
// 关闭并重开后,即使服务端回执已过期,本地持久化成功证据仍可配合完整读取完成接纳,无需重发。
test('a locally durable success proof survives server receipt expiry without resending the original operation',async()=>{
const f=fixture(),{engine,request}=await applied(f);await engine.close();f.receipts.clear();const recovered=await f.open();await recovered.settle();
const session=await recovered.prepareRecovery();assert.deepEqual(session.acknowledgedRequestIds,[request.id]);await session.apply();await recovered.settle();
@@ -1,23 +1,37 @@
/**
* 供浏览器存储 runner 调用的真实 IndexedDB 接纳场景,不是 node:test 自动收集的文件。
* 先由源项目写入共享字节,再从目标项目授权复用,重开后验证 binding、显示、历史及关闭项目的持久化 pin。
*/
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.ts';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
// 每次使用独立数据库;assert 由浏览器 runner 注入,finally 释放 leader/runtime 并请求删库。
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());
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:[]});
// 源字节已经预热,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');}};
// 简化目标文档服务:提交应用变化并保存 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)};
// 重新装配 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]]));};
// 在接纳资源的同步事务内创建 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}}}));
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();
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();
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;
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.
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();}
await leader.release();leader=undefined;await runtime.close();runtime=undefined;
}finally{await leader?.release();await runtime?.close();storage.close();indexedDB.deleteDatabase(name);}
+58 -2
View File
@@ -1,22 +1,65 @@
/**
* 已有资源接纳(adoption)与文档图片导入的引擎测试。复用原始字节仍需重新授权,不生成多余上传任务。
* 绑定、文档、history、ledger 和导入进度通过一致状态根发布,资源字节及引用通过相应持久化 cut 保存。
* 内存端口可控制授权、配额和提交时机;真实 IndexedDB 的事务/回收竞态由 browser-cases 另测。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRegistry,builtinComponents,createDocument,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.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';
// 计算真实 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('');
// 不可变资源引用同时包含 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'}};
// 使用接纳句柄创建 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}});
// 为各项目维护独立文档/回执/leader,同时在同账户工作区内共享 depot 字节;开关可阻塞授权或模拟配额失败。
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;
// 共享缓存键含账户、工作区及 authority+assetId;项目 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;}};
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');}};
// 最小存储端口:校验活动 leader,保存文件、image 及 depot writes;cache collect 故意不报告完整扫描。
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)}};}
// 暴露时间、授权、持久化和挂起开关;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});}};
}
// 把接纳和创建图片放在同一个同步事务内,确保不会只留下孤立 binding。
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()=>{
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();
@@ -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);
}finally{await engine.close();}
});
// 第二个项目复用共享字节但再次授权;权限撤销后即使缓存仍在,也不能修改状态根。
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{
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);
}finally{await q.close();}
});
// 重复 ID 导致事务拒绝、异步事务或空事务都不能留下半份 binding/history,后续落盘也不应写缓存。
test('rejected and asynchronous transactions cannot leave a binding or history behind',async()=>{
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);
@@ -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);
}finally{await engine.close();}
});
// binding 只能由接纳路径管理;格式降级、操作/资源身份不符和伪造 digest 无法通过恢复,换 authority 也不能解释旧绑定。
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');
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'});
}finally{await engine.close();}
});
// 配额失败保留本地可见图片,但阻止文档网络派发;修复后先使原子 cut 持久化,再允许发送一次文档请求。
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();}
});
// 授权仍挂起时关闭引擎,迟到授权结果不能发布内容、绑定或共享缓存,关闭过程仍能完成。
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);
});
// WorkspaceRuntime 为多个项目提供同一个 depot;关闭项目后再关闭共享资源和存储,不能冻结调用者 storage 对象。
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)});
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);
});
// 旧的无引用状态根正在落盘时清除字节,再接纳新图片;新字节必须留到匹配的新 cut 持久化,不能被旧提交释放。
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;
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);
}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:{}})]);
// 文档导入授权原字节,并以同一状态根发布绑定、内容和 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();}});
// 取消挂起的授权后迟到结果不能发布;后续权限拒绝的导入保留 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();}});
// 同一文档批次接纳两个资源时,绑定和元素数量一致,完成记录可由恢复 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();}});
// 非法图片几何使导入事务拒绝,即使已经授权也不能留下 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('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 {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
// 注入断言并创建独立数据库;所有领取的 leader 统一记录,最后释放并删库。
export async function assetDepotStorageCase(assert){
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';
// 构造不同资源 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 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;};
try{
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(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);});
// 直接统计 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);});
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');
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 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');
// 缺少索引、不完整或落后于最新 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');
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(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,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');
// 清除最后引用后字节应消失,但不可变身份与 purgedAt 仍保留,便于校验后续补回的内容。
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([['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.
@@ -37,15 +53,21 @@ export async function assetDepotStorageCase(assert){
// 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');
// 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;
// 模拟上传回执及 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};
// 文档提交保存新 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 engine=await openProjectEngine({scope:{...scope,projectId},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,createId:()=>`engine-${++counter}`});
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');
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();}
// 读取引擎项目的 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');
// 共享 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');
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();
+14
View File
@@ -1,39 +1,53 @@
/**
* 共享资源 depot、原始二进制传输及 Foundry authority 的单元测试。缓存降低读取成本,不能代替授权或身份验证。
* 控制权限和生命周期可检查迟到结果隔离;字节预算按实际流内容验证,不依赖声明长度。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createAssetDepot} from '../../src/lib/canvas-runtime/asset-depot.ts';
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.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('');
// 准备可替换缓存、权限及身份状态的 depot;resolve/read 分别计数,区分重新授权和重新下载。
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'}};
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});
// 每次 resolve 都检查权限并返回引用,read 单独提供原始字节,便于断言热缓存仍授权。
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})};
}
// 首次接纳下载原字节,热缓存再次接纳只重新授权;撤权后不能以缓存绕过 resolve,读取次数保持不变。
test('new cross-project adoption reauthorizes even when immutable bytes are warm',async()=>{
const f=await fixture(),signal=new AbortController().signal;
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.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()=>{
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,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();
});
// authority 结果返回前关闭 depot,迟到结果应以 identity_changed 拒绝,不能写入元数据缓存。
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 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()=>{
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 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 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()=>{
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'}}};}});
+12
View File
@@ -1,19 +1,28 @@
/**
* Foundry 上传 adapter 与 multipart transport 的协议测试。冻结请求关联资源 ID、操作 ID、摘要及字节长度。
* 回执未知或权限失败不等于零写入,显示地址可由宿主路由,但 canonical 身份和 authority 必须保持。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
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')}`;
// 构造真实 Blob、摘要、冻结上传 body 与匹配成功回执;respond 只替换模拟服务端响应。
async function fixture() {
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 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,
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}` } };
let response = { status: 201, body: receipt };
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; } };
}
// 上传保持文件名、原字节及请求身份;accept 拒绝不匹配回执,换字节重试在发请求前就被阻塞。
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);
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);
});
// 宿主只替换 displayURL,不能改变 sourceURL 或 authority;单纯计算显示路径也不能触发网络。
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 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);
});
// 查询 404 unknown 保持 unresolved、403 阻塞自动处理,成功响应若 requestId 错误仍不能当作已应用证明。
test('missing and rejected upload receipts never become proof of zero writes', async () => {
const f = await fixture(), signal = new AbortController().signal;
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');
});
// FormData 保持原对象交给 fetch,由 fetch 生成 multipart boundary;同时传入账户、资源和幂等身份头。
test('multipart browser transport leaves the boundary to fetch and binds the expected account', async () => {
let captured;
const transport = createFoundryTransport({ accountId: id(5), isCurrentIdentity: () => true, fetch: async (url, init) => { captured = { url, init }; return Response.json({ status: 'applied' }); } });
+18
View File
@@ -1,14 +1,22 @@
/**
* 发送批次的稀疏变更压缩测试。wire操作保留全部逻辑ID,压缩只改变传输changes,不改ledger/history输入。
* 存在基线、前置条件、生命周期与UTF-8实际字节成本约束,不以“最终对象一样”就无条件消去中间意图。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDocument, applyChanges } from '../../src/lib/canvas-core/document.ts';
import { createLedger, compressChanges } from '../../src/lib/canvas-runtime/ledger.ts';
import { projectBatch } from '../../src/lib/canvas-runtime/batch-projection.ts';
// root/child用于参与范围护栏对照,默认node同时含未来嵌套字段,检查位置压缩不覆盖业务数据。
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 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});
// 放大中间值让确定安全的连续编辑压缩更可能减少真实wire体积。
const long='intermediate '.repeat(200);
/** 按顺序创建相依intent并入ledger,首次身份存在性来自base/显式presence;project允许缩小参与canvas范围。 */
function fixture(changes,base=document,presence){
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}));
@@ -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);}
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]});
// original→长中间值→final可合成一个字段修改,但两逻辑ID都在;比较wire前后ledger及完整apply结果。
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();
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);
});
// 几何合成保留起始before,最终坐标正确且title/future业务数据完整,不用最后节点覆盖整对象。
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();
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);
});
// new创建/更新/删除最终无节点但有墓碑;wire仍留创建与删除,不把生命周期错误压成无操作。
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 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))));
});
// 可选字段从absent到值再absent,净值虽零仍保留前置存在性检查,apply结果与逐项操作相同。
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 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);
});
// unknown/absent矛盾、缺基线、已冻结或blocked都回退原始顺序,不擅自重压可能发送的意图。
test('unknown or inconsistent baselines and frozen or blocked records retain ordered original changes',()=>{
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})));
@@ -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',()=>{
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);
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',()=>{
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}}]){
@@ -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',()=>{
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');}}};
@@ -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',()=>{
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]);
+20
View File
@@ -1,13 +1,21 @@
/**
* bindings与宿主生命周期的组合测试。涵盖树投影、坐标/曲线转换、连接手势、账户代次与工作区关闭,以及未知类型占位。
* 部分用例用内存storage/persistence演示持久化接口,不是磁盘崩溃或真实HTTP集成验证。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
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 { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
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 } });
/** 父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 };
// 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]);
// 根视图显示子节点偏移,折叠把边显示端点转frame但文档仍inside;独立打开child使用原局部坐标。
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');
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();
});
// 90度/0.5倍变换点和控制向量往返误差小于1e-9,extension未知字段保留。
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 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);
});
// 一视图占连接手势时另一视图节点/连接begin都busy;20次预览不记历史,提交一次,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 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();
});
// 同账号workspace复用,切号使旧current失效并增authEpoch;retire在await close前先封身份,host关闭后不可再取workspace。
test('host scopes account epochs, preserves workspace owners across navigation, and fences before asynchronous close', async () => {
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; } });
@@ -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/);
});
// 远端path覆盖使连接预览取消,已提交remote路径保持且不新建本地历史。
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');
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();
});
// 两并发open复用engine,detach不丢文档;存储quota使close失败,leader/资源池保留但新编辑拒绝,修复后重试才释放。
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');
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);
});
// 未来path允许外观编辑但拒绝路径重写,文档原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 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);
});
// 第一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 () => {
const { guardEditingLease } = await import('../../src/components/canvas/editing-lease.ts');
const editor = createEditor({ document, registry }), sessions = createSessions(editor); sessions.attach('view');
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') }); };
sessions.begin('view', [target]); const first = lease(); first.preview({ x: 100 }); first.cancel();
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();
});
// 构造未知/未来节点与边的异常业务字段,投影只读安全包络并按边补去重Handle;原文档序列化值不变。
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 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);
});
// 未来frame隐藏内部显示树并重定向上级边,保留占位Handle;真实端点与子树数据不删除。
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');
});
// 已知自定义组件无renderer显示占位,但安装了movable仍可拖/dispatch移动,不能一律当未知不可编辑。
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 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);});
@@ -1,30 +1,54 @@
/**
* 真实浏览器存储的 Blob checkpoint 场景,由 browser-storage-cases 调用并注入断言函数。
* 分别处理 DB schema 4→5、checkpoint 编码版本与恢复镜像格式版本,三个版本号不是同一时钟。
* 覆盖旧行升级、精确字节校验、未来格式、事务回滚、owner 失效与被旧连接阻挡的升级。
*/
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
import {sealRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
// 手工建立旧数据库后再通过生产 storage 升级,直接观察持久化行及重新打开后的恢复结果。
export async function blobCheckpointCase(assert){
// 随机数据库名隔离场景;镜像含 Unicode、大量文本和固定请求字节,用来检查序列化保持精确内容。
const name=`blob-checkpoint-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},key=JSON.stringify(['a','w','p']),image=revision=>({formatVersion:1,rootRevision:revision,localRevision:revision,payload:{rows:Array.from({length:250},(_,i)=>({id:i,text:`版本 ${revision} 👀 ${'x'.repeat(100)}`})),request:{id:'frozen',body:'{"exact":"original bytes"}'}}});
// 三个 Promise 工具分别等待 IDB 请求、事务完成/abort 和原始 Blob SHA-256;事务成功不能只看单个 put 成功。
const read=req=>new Promise((resolve,reject)=>{req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);}),done=tx=>new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);}),hash=async blob=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',await blob.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
// 手工打开 DB v4 并创建旧表,绕开新适配器自动建库,保证后面确实经历旧 checkpoint 升级。
let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name,4);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
// 把旧对象镜像与其 checksum 一起写入 revision 0,并关闭旧连接后再启动新适配器。
const initial=image(0),checksum=await hash(new Blob([JSON.stringify(initial)]));let tx=raw.transaction(['metadata','checkpoints'],'readwrite'),terminal=done(tx);tx.objectStore('metadata').put({fence:0,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image:initial,checksum},[key,0]);await terminal;raw.close();let storage=createBrowserStorage({name,checkpointEvery:1}),leader;
try{
// 先核对旧对象恢复不变,再提交带 sealed 镜像与文件的首个新切点。
leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(initial),'DB v4 legacy checkpoint changed during upgrade');const first=image(1);await storage.commit(leader,{image:sealRecoveryImage(1,1,1,first.payload),files:[{id:'kept',bytes:new Blob(['kept bytes'])}]});
// DB 应升级为 5;新 checkpoint 存 Blob 而非克隆文档对象,文本和 checksum 覆盖精确序列化字节。
raw=await read(indexedDB.open(name));assert(raw.version===5,'Blob cuts did not fence old DB clients');const saved=await read(raw.transaction('checkpoints').objectStore('checkpoints').get([key,1]));assert(saved.checkpointVersion===1&&saved.bytes instanceof Blob&&!saved.image,'full cut is still a cloned document object');assert(await saved.bytes.text()===JSON.stringify(first),'Blob checkpoint changed serialized bytes');assert(saved.checksum===await hash(saved.bytes),'Blob checksum did not cover exact source bytes');
// 旧 DB v4 客户端再打开必须得到 VersionError,防止老解释器接受新 Blob 行。
const old=await new Promise(resolve=>{const request=indexedDB.open(name,4);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(old==='VersionError','DB v4 reader accepted Blob checkpoint rows');
// 用原始 IDB 连接替换指定 checkpoint 行,构造损坏输入后仍等待事务真正完成。
const write=async value=>{const tx=raw.transaction('checkpoints','readwrite'),terminal=done(tx);tx.objectStore('checkpoints').put(value,[key,1]);await terminal;};
// 分别破坏字节、header、编码版本和 UTF-8;每次恢复检查对应错误,并还原原行供下一场景继续。
for(const [kind,expected]of [['bytes','recovery_checksum_mismatch'],['header','recovery_checksum_mismatch'],['future','recovery_checkpoint_interpreter_unavailable'],['utf8','corrupt_recovery_image']]){
const damaged=structuredClone(saved);if(kind==='bytes')damaged.bytes=new Blob([JSON.stringify(image(9))]);else if(kind==='header')damaged.header.formatVersion=2;else if(kind==='future')damaged.checkpointVersion=2;else{damaged.bytes=new Blob([new Uint8Array([123,34,120,34,58,34,255,34,125])]);damaged.checksum=await hash(damaged.bytes);}
await write(damaged);let code;try{await storage.recover(leader);}catch(error){code=error.code;}assert(code===expected,`${kind} cut was accepted: ${code}`);if(kind==='future'){code=undefined;try{await storage.commit(leader,{image:image(2)});}catch(error){code=error.code;}assert(code===expected,'writer replaced a future checkpoint');}await write(saved);
}
// 暂时包装原生 put:先排入写请求再抛错,把故障放在事务已安排部分写入的窗口。
const nativePut=IDBObjectStore.prototype.put;let injected=false,error;
// 只在 revision 2 的新 Blob checkpoint 写入点注入异常;其他表/版本继续调用真实 IndexedDB。
IDBObjectStore.prototype.put=function(value,k){const request=nativePut.call(this,value,k);if(this.name==='checkpoints'&&value?.checkpointVersion===1&&value.header.rootRevision===2){injected=true;throw Error('abort_blob_checkpoint');}return request;};
// 带新文件的提交失败后恢复原 put,再验证旧 Root 仍可读且新文件字节没有泄漏。
try{await storage.commit(leader,{image:image(2),files:[{id:'orphan',bytes:new Blob(['must rollback'])}]});}catch(cause){error=cause.message;}finally{IDBObjectStore.prototype.put=nativePut;}
assert(injected&&error==='abort_blob_checkpoint','checkpoint fault was not injected after put');assert((await storage.recover(leader)).rootRevision===1&&await storage.readFile(leader,'orphan')===undefined,'aborted Blob cut leaked head or file bytes');
// 释放并重建适配器,检查 Blob 与同批文件在新 owner 下仍能恢复。
await leader.release();storage.close();storage=createBrowserStorage({name,checkpointEvery:1});leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(first),'fresh adapter lost Blob content');assert(await(await storage.readFile(leader,'kept')).text()==='kept bytes','Blob reopening lost atomic file');
// 大切点准备期间释放 owner,完成结果必须是 storage_leader_lost;重新 claim 后不得看到新切点或晚到文件。
const large=sealRecoveryImage(1,2,2,{rows:Array.from({length:16000},(_,i)=>({id:i,text:'x'.repeat(100)}))});let failure;const pending=storage.commit(leader,{image:large,files:[{id:'late',bytes:new Blob(['late'])}]}).catch(error=>{failure=error.code;});await Promise.resolve();await leader.release();await pending;assert(failure==='storage_leader_lost','released owner published or changed its cancellation error');leader=await storage.claim(scope);assert((await storage.recover(leader)).rootRevision===1&&await storage.readFile(leader,'late')===undefined,'released owner advanced Blob checkpoint');
// 关闭原始连接、释放 owner 和 adapter,再发起删除数据库;这里不等待删除完成事件。
}finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
}
// 保持旧 DB 连接打开以阻挡升级;同一失败 adapter 保留原错误,旧连接退场后需新 adapter 才能成功。
export async function blockedUpgradeCase(assert){
// 本场景只需一个仍打开的 v4 数据库,不依赖上一个升级场景留下的数据。
const name=`blocked-upgrade-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},read=req=>new Promise((resolve,reject)=>{req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});let raw=await read(indexedDB.open(name,4)),storage=createBrowserStorage({name}),leader;
// 重复 claim 都应报告 storage_upgrade_blocked;丢弃的升级请求不能在稍后悄悄升级,关旧连接并新建 adapter 后才升到 5。
try{let code;try{await storage.claim(scope);}catch(error){code=error.code;}assert(code==='storage_upgrade_blocked','active old connection did not reject the upgrade');code=undefined;try{await storage.claim(scope);}catch(error){code=error.code;}assert(code==='storage_upgrade_blocked','blocked adapter lost the original failure reason');raw.close();raw=await read(indexedDB.open(name));assert(raw.version===4,'abandoned upgrade changed database version after rejection');raw.close();storage.close();storage=createBrowserStorage({name});leader=await storage.claim(scope);assert(await storage.recover(leader)===null,'fresh adapter could not open after blocked request was retired');raw=await read(indexedDB.open(name));assert(raw.version===5,'fresh upgrade failed after abandoned open');}
finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
}
+8 -2
View File
@@ -1,13 +1,19 @@
/**
* 浏览器摘要适配器的协议故障测试:使用可控 Worker 替身检查请求/响应身份,以及启动和运行失败的错误区分。
* 不启动真实线程,也不验证浏览器打包;每个用例恢复 globalThis.Worker,避免污染其他测试。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createBrowserDigests} from '../../src/lib/canvas-runtime/browser-digests.ts';
// Worker 回传错误 id 即使摘要格式正确也必须拒绝;故障 worker 需 terminate,不能把别的任务结果交给当前调用者。
test('browser digest adapter rejects mismatched worker identity and terminates the worker',async()=>{
const previous=globalThis.Worker;let terminated=0;
globalThis.Worker=class{postMessage(value){queueMicrotask(()=>this.onmessage({data:{version:1,id:value.id+1,byteLength:value.bytes.size,sha256:'a'.repeat(64)}}));}terminate(){terminated++;}};
globalThis.Worker=class{/* 模拟线程稍后回信,故意把 id 加一;其余字段合法以隔离身份校验。 */ postMessage(value){queueMicrotask(()=>this.onmessage({data:{version:1,id:value.id+1,byteLength:value.bytes.size,sha256:'a'.repeat(64)}}));}/* 计数故障后的终止动作。 */ terminate(){terminated++;}};
const pool=createBrowserDigests();try{await assert.rejects(pool.digest(new Blob(['bytes'])),/invalid_digest_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
});
// 构造器抛错映射为 worker_unavailable,onerror 映射为 worker_failed;两个路径都不能给调用者返回伪造摘要。
test('browser digest adapter exposes worker startup and runtime failures without returning a false hash',async()=>{
const previous=globalThis.Worker;let pool;
try{globalThis.Worker=class{constructor(){throw Error('denied');}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_unavailable/);pool.close();let terminated=0;globalThis.Worker=class{postMessage(){queueMicrotask(()=>this.onerror({preventDefault(){}}));}terminate(){terminated++;}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_failed/);assert.equal(terminated,1);}finally{pool?.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
try{globalThis.Worker=class{/* 在线程创建阶段抛错,尚未进入 postMessage。 */ constructor(){throw Error('denied');}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_unavailable/);pool.close();let terminated=0;globalThis.Worker=class{/* 在线程运行阶段触发 onerror,区别于构造失败。 */ postMessage(){queueMicrotask(()=>this.onerror({preventDefault(){}}));}/* 计数故障后的终止动作。 */ terminate(){terminated++;}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_failed/);assert.equal(terminated,1);}finally{pool?.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
});
+13
View File
@@ -1,21 +1,32 @@
/**
* 浏览器迁移控制器与旧画布编辑租约测试。旧编辑视图持 shared lease,迁移要求独占且不等待的 lease,避免新旧写入并行。
* 使用内存 localStorage/Web Locks 替身验证协调逻辑,不代表真实多标签 Web Locks 已在本文件执行。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {claimLegacyCanvasEditing,createBrowserCanvasMigration} from '../../src/lib/browser-canvas-migration.ts';
import {canvasMigrationStorageKey} from '../../src/lib/canvas-migration.ts';
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
// 确定性 UUID 及账户/workspace 作用域;editing 锁名与迁移持久化键都从此作用域派生。
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,scope={accountId:id(1),workspaceId:id(2)},root=id(3),registry=createRegistry(builtinComponents());
// 搭建 records、held 锁表及调用记录,controller 控制当前视图生命周期。
function fixture(){
const records=new Map(),held=new Map(),calls=[];
// 最小 localStorage 接口,保存原字符串以检查未来/损坏材料不会被覆盖。
const storage={getItem:key=>records.get(key)??null,setItem:(key,value)=>records.set(key,value),removeItem:key=>records.delete(key)};
// 异步模拟共享/独占互斥:不可用时调用 work(null),成功期间增加持有计数,finally 总是归还锁。
const locks={request(name,options,work){calls.push({name,options});return Promise.resolve().then(async()=>{
// shared 只排斥 exclusive,exclusive 排斥任意持有者;这是 fixture 的即时 ifAvailable 模拟,不实现公平等待队列。
const state=held.get(name)??{shared:0,exclusive:false},granted=options.mode==='shared'?!state.exclusive:!state.exclusive&&!state.shared;
if(!granted)return work(null);
if(options.mode==='shared')state.shared++;else state.exclusive=true;held.set(name,state);
try{return await work({name,mode:options.mode});}finally{if(options.mode==='shared')state.shared--;else state.exclusive=false;if(!state.shared&&!state.exclusive)held.delete(name);}
});}};
// 所有租约和迁移实例共享当前 AbortSignal;isCurrentIdentity 可在用例中覆写为过期身份。
const controller=new AbortController(),options={scope,registry,locks,storage,signal:controller.signal,isCurrentIdentity:()=>true};
return{records,held,calls,controller,options};
}
// 两个旧编辑器可共享锁,释放一个仍不能迁移;全部释放后 exclusive 迁移检查可进入 idle,所有请求均 ifAvailable。
test('editable legacy views share a lease and exclude migration until every editor releases',async()=>{
const f=fixture(),one=await claimLegacyCanvasEditing({...f.options,canvasId:root}),two=await claimLegacyCanvasEditing({...f.options,canvasId:root});
const migration=createBrowserCanvasMigration(f.options);
@@ -24,6 +35,7 @@ test('editable legacy views share a lease and exclude migration until every edit
assert.ok(f.calls.every(call=>call.name===canvasMigrationStorageKey(scope)+':editing'&&call.options.ifAvailable===true));
assert.deepEqual(f.calls.slice(0,2).map(call=>call.options.mode),['shared','shared']);assert.ok(f.calls.slice(2).every(call=>call.options.mode==='exclusive'));
});
// pending 迁移只阻止受影响旧 canvas,其他 canvas 可编辑;未来记录版本无法解释时拒绝但原字符串保持。
test('pending migration blocks affected old views and corrupt records cannot enable editing',async()=>{
const f=fixture(),key=canvasMigrationStorageKey(scope),job={version:1,...scope,rootId:root,id:id(4),phase:'pending',body:JSON.stringify({protocol_version:2,request_id:id(4),expected_versions:{[root]:1}})};
f.storage=f.options.storage;f.storage.setItem(key,JSON.stringify(job));
@@ -32,6 +44,7 @@ test('pending migration blocks affected old views and corrupt records cannot ena
const saved=JSON.stringify({version:2,data:'retain'});f.storage.setItem(key,saved);
await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.held.size,0);
});
// 视图 abort 或身份失效不能取得迟到编辑授权;已领取和未领取路径都归还锁,重复 release 安全。
test('aborted or stale view lifetimes release leases and cannot acquire a late editing grant',async()=>{
const f=fixture(),lease=await claimLegacyCanvasEditing({...f.options,canvasId:root});f.controller.abort();await lease.release();assert.equal(f.held.size,0);await lease.release();
const g=fixture(),waiting=claimLegacyCanvasEditing({...g.options,canvasId:root});g.controller.abort();await assert.rejects(waiting,{name:'AbortError'});assert.equal(g.held.size,0);
+23 -1
View File
@@ -1,4 +1,8 @@
/** Real Chromium IndexedDB/Web Locks checks, in an isolated temporary browser profile. */
/**
* 使用隔离 Chrome profile 运行真实 IndexedDB/Web Locks 场景,或以 --package 消费构建后的浏览器 ESM。
* 本地 HTTP server 只提供测试页面、源码/发布文件和可控 NDJSON 流;普通模式通过 Node 去除 .ts 类型,不运行应用 bundler。
* 此入口内联了一套简化 CDP 请求表,未使用 cdp-client 的连接级超时/故障保护,不能把 Runtime.evaluate 的 timeout 当作所有命令的截止时间。
*/
import { createServer } from 'node:http';
import { readFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@@ -7,18 +11,24 @@ import { spawn } from 'node:child_process';
import { stripTypeScriptTypes } from 'node:module';
import { setTimeout as pause } from 'node:timers/promises';
// 从脚本位置定位仓库,随机临时 profile 保证测试不读写日常 Chrome 会话。
const root = resolve(import.meta.dirname, '../..'), profile = await mkdtemp(join(tmpdir(), 'endoleap-storage-browser-'));
// --package 只影响发布文件服务和页面导入入口;它不会在这里自动构建 .headless-dist。
const packageMode=process.argv.includes('--package');
// 按 key 保留挂起的响应与读取次数,供页面测试控制共享读取的首块、尾块和失败尾部。
const readStreams=new Map();
// 只在回环地址提供 fixture;源码读取或类型去除失败时返回 500 供页面检查报告。
const server = createServer(async (request, response) => {
try {
const path = new URL(request.url, 'http://localhost').pathname;
// 同一 key 每次读取计数增加,立即发送第一行并保持连接;由独立 release 请求决定何时结束。
if(path==='/loader-stream'){
const url=new URL(request.url,'http://localhost'),key=url.searchParams.get('key');
if(!key){response.writeHead(400).end();return;}
const state=readStreams.get(key)??{reads:0};state.reads++;state.response=response;readStreams.set(key,state);
response.writeHead(200,{'Content-Type':'application/x-ndjson'});response.write(JSON.stringify({part:0,complete:false})+'\n');return;
}
// 查找挂起流,可发送合法完成行或非法 JSON 尾部;返回读次数用于核对共享加载没有重复 fetch。
if(path==='/loader-release'){
const url=new URL(request.url,'http://localhost'),state=readStreams.get(url.searchParams.get('key'));
if(!state){response.writeHead(404).end();return;}
@@ -26,30 +36,39 @@ const server = createServer(async (request, response) => {
response.setHeader('Content-Type','application/json');response.end(JSON.stringify({reads:state.reads}));return;
}
if (path === '/') { response.setHeader('Content-Type', 'text/html'); response.end('<title>Headless storage checks</title>'); return; }
// 包模式从 .headless-dist 读取,其余只允许 src/lib 和 tests/headless;resolve 后再检查目录前缀。
const isPackage=packageMode&&path.startsWith('/package/');
const filename = isPackage?resolve(root,'.headless-dist',path.slice('/package/'.length)):resolve(root, `.${path}`);
if (!(isPackage&&filename.startsWith(`${root}/.headless-dist/`)) && !filename.startsWith(`${root}/src/lib/`) && !filename.startsWith(`${root}/tests/headless/`)) { response.writeHead(404).end(); return; }
// 仅对 .ts 去除类型后提供 JavaScript;.mjs 和编译包文件保持读取内容,不进行应用级转换。
let source = await readFile(filename, 'utf8');
if (filename.endsWith('.ts')) source = stripTypeScriptTypes(source);
response.setHeader('Content-Type', 'text/javascript'); response.end(source);
} catch (error) { response.writeHead(500).end(String(error)); }
});
// 先绑定随机本地端口,再用独立 profile 启动 headless Chrome;CHROME_BIN 可覆盖默认安装路径。
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, `http://127.0.0.1:${server.address().port}/`], { stdio: 'ignore' });
let socket;
try {
// 最多轮询 100 次 DevToolsActivePort,每次间隔 100 毫秒;未找到端口即失败,不连接用户现有浏览器。
let port;
for (let attempt = 0; attempt < 100; attempt++) {
try { port = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); }
}
if (!port) throw new Error('isolated Chromium did not start');
// 从本次 Chrome 的调试服务查找 page,建立 WebSocket 后才发送 CDP 命令。
const tabs = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json());
socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl);
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
// pending 仅按 ID 路由 Promise;这里没有每条命令的本地截止定时器。
let next = 0; const pending = new Map();
// 只处理带 ID 的响应,协议错误拒绝相应 Promise;没有转发无 ID 事件或整连接失败处理。
socket.onmessage = event => { const message = JSON.parse(event.data); if (message.id) { const callbacks = pending.get(message.id); pending.delete(message.id); if (message.error) callbacks.reject(new Error(JSON.stringify(message.error))); else callbacks.resolve(message.result); } };
// 登记递增 ID 后发送命令,等待对应响应;其行为不同于独立 createCdpCalls 的有界调用器。
const call = (method, params) => new Promise((resolve, reject) => { const id = ++next; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); });
const origin = `http://127.0.0.1:${server.address().port}`;
// 导航到本次 fixture origin,随后最多 100 次检查页面 origin 与 readyState,避免误在空白页执行。
await call('Page.navigate', { url: `${origin}/` });
let loaded = false;
for (let attempt = 0; attempt < 100; attempt++) {
@@ -58,10 +77,13 @@ try {
await pause(50);
}
if (!loaded) throw new Error('browser test origin did not finish loading');
// 页面动态导入普通存储场景或编译包场景并等待 run;30 秒 timeout 是该 evaluate 的协议参数。
const result = await call('Runtime.evaluate', { expression: `import('/tests/headless/${packageMode?'package-browser-cases':'browser-storage-cases'}.mjs').then(module => module.run())`, awaitPromise: true, returnByValue: true, timeout: 30000 });
// 页面异常直接使 runner 失败,只有成功返回的场景消息才逐条打印 PASS。
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
for (const message of result.result.value) console.log(`PASS ${message}`);
} finally {
// 清理连接并请求 Chrome 退出,等待进程 exit 后再关闭 HTTP server 和删除 profile;此等待没有额外强杀截止时间。
socket?.close();
const exited = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); });
chrome.kill(); await exited;
+14 -1
View File
@@ -1,19 +1,32 @@
/** Bounded protocol calls for isolated browser fixtures, never product code. */
/**
* 隔离浏览器 fixture 专用的有界 CDP 请求路由器,不作为产品运行时代码。
* 每个正整数 ID 对应一个 Promise 和截止定时器;连接/目标级故障是整个实例的终态。
* 本函数不负责建立或关闭 socket,也不提供独立 dispose;外层 runner 持有连接。
*/
// 接收已连接 socket 和可替换定时器;默认给每个调用设置 30 秒截止定时器,事件由 onEvent 转发。
export function createCdpCalls(socket,{timeoutMs=30000,onEvent=()=>{},setTimer=setTimeout,clearTimer=clearTimeout}={}){
// pending 是未回应调用的唯一查找表;failure 保留首个致命错误,使未来调用立刻拒绝。
let serial=0,failure;const pending=new Map();
// 全局失败清除每个截止定时器并拒绝所有 Promise;重复故障不覆盖最初原因。
const fail=error=>{failure??=error;for(const row of pending.values()){clearTimer(row.timer);row.reject(failure);}pending.clear();};
// 连接关闭和传输错误都不再允许继续发命令。
socket.addEventListener('close',()=>fail(Error('CDP connection closed')));
socket.addEventListener('error',()=>fail(Error('CDP connection failed')));
// 收到消息先解析 JSON;解析或事件回调异常同样会进入全局失败。
socket.addEventListener('message',event=>{
try{
const message=JSON.parse(event.data);
// 无请求 ID 的消息是事件;先通知观察者,再把崩溃、脱离或对话框视为目标不可继续使用。
if(!message.id){onEvent(message);if(['Inspector.targetCrashed','Inspector.detached','Target.targetCrashed','Page.javascriptDialogOpening'].includes(message.method))fail(Error('CDP target unavailable: '+message.method+' '+JSON.stringify(message.params??{})));return;}
// 已超时/完成的响应无对应 row 时忽略;普通协议 error 只拒绝该请求,不使整个连接失效。
const row=pending.get(message.id);if(!row)return;pending.delete(message.id);clearTimer(row.timer);message.error?row.reject(Error(JSON.stringify(message.error))):row.resolve(message.result);
}catch(error){fail(error);}
});
// 为新命令分配递增 ID,先登记 pending 与定时器再发送;send 同步抛错也会拒绝全部在途工作。
return(method,params={})=>new Promise((resolve,reject)=>{
if(failure){reject(failure);return;}
const id=++serial,row={resolve,reject,timer:undefined};pending.set(id,row);
// 任意一个调用超时都使整个调用器失败,避免测试随后无限挂在另一个依赖命令。
row.timer=setTimer(()=>fail(Error(`CDP ${method} timed out after ${timeoutMs}ms`)),timeoutMs);
try{socket.send(JSON.stringify({id,method,params}));}catch(error){fail(error);}
});
+9
View File
@@ -1,8 +1,17 @@
/**
* 浏览器测试专用 CDP 调用器的单元测试。用 socket/定时器替身验证请求 ID 路由、截止时间及致命故障,不启动浏览器。
* 单个协议命令错误只拒绝对应请求;连接或目标级故障则拒绝整个调用器及未来调用。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createCdpCalls} from './cdp-client.mjs';
// 把 socket 事件、已发送命令和定时器保存在 Map/数组,测试可直接投递乱序响应或手动触发超时。
function fixture(){const handlers=new Map(),sent=[],timers=new Map();let id=0;const socket={addEventListener:(event,callback)=>handlers.set(event,callback),send:text=>sent.push(JSON.parse(text))},events=[],call=createCdpCalls(socket,{timeoutMs:30,onEvent:event=>events.push(event),setTimer:callback=>{timers.set(++id,callback);return id;},clearTimer:key=>timers.delete(key)});return{call,sent,timers,events,event:(name,data)=>handlers.get(name)({data:JSON.stringify(data)})};}
// 两个响应反序返回仍按 ID 完成各自 Promise;无 ID 事件交给 onEvent,成功响应清除对应定时器。
test('CDP responses route by request ID, forward events and release deadline timers',async()=>{const f=fixture(),one=f.call('one'),two=f.call('two');f.event('message',{method:'Runtime.event'});f.event('message',{id:2,result:{value:'two'}});f.event('message',{id:1,result:{value:'one'}});assert.deepEqual(await one,{value:'one'});assert.deepEqual(await two,{value:'two'});assert.equal(f.events.length,1);assert.equal(f.timers.size,0);});
// 一个调用超时即终止全部 pending 和未来调用;迟到响应被忽略,不产生额外报告读取来掩盖超时。
test('a stalled CDP call rejects all pending work and cannot hide behind another unbounded report read',async()=>{const f=fixture(),one=assert.rejects(f.call('Runtime.evaluate'),/timed out/),two=assert.rejects(f.call('Page.captureScreenshot'),/timed out/);[...f.timers.values()][0]();await Promise.all([one,two]);assert.equal(f.timers.size,0);f.event('message',{id:1,result:{late:true}});await assert.rejects(f.call('Runtime.evaluate'),/timed out/);});
// 分别注入连接关闭、错误、目标崩溃、脱离和对话框事件;所有情况清空 pending,调用器不会自动接受对话框。
test('connection loss and fatal target events reject pending calls without accepting dialogs',async()=>{for(const event of ['close','error','Inspector.targetCrashed','Target.targetCrashed','Inspector.detached','Page.javascriptDialogOpening']){const f=fixture(),result=assert.rejects(f.call('Runtime.evaluate'),/CDP/);event.includes('.')?f.event('message',{method:event}):f.event(event);await result;assert.equal(f.timers.size,0);assert.deepEqual(f.sent.map(row=>row.method),['Runtime.evaluate']);}});
// 普通命令错误只拒绝它自己,另一命令仍可成功且两者都清除定时器。
test('one protocol command error does not corrupt another request or leak its timer',async()=>{const f=fixture(),one=assert.rejects(f.call('bad'),/invalid/),two=f.call('good');f.event('message',{id:1,error:{message:'invalid'}});f.event('message',{id:2,result:{ok:true}});await one;assert.deepEqual(await two,{ok:true});assert.equal(f.timers.size,0);});
+56 -1
View File
@@ -1,3 +1,7 @@
/**
* 显式冲突恢复测试:用户选择 keep-local 或 discard-local 后,基于已审阅的远程基线重建条件操作和撤销链。
* 旧请求及拒绝证据保留在账本与恢复审计中;结果未知的操作不能靠用户选择直接退休,付费动作不能自动重启。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
@@ -5,19 +9,56 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
// 共享类型定义和目标身份,保证冲突处理仍执行数据校验及 incarnation 校验。
const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1};
// 单元素基线同时包含文字和几何,便于区分局部字段重放与整对象覆盖。
const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'canvas',data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}};
// 独立的初始项目文档;服务替身和新引擎都从此基线开始。
const original=createDocument('project',[{id:'canvas',parent:null}],[element]);
// 推进有限轮微任务,供恢复读取进入挂起点;不等待被故意阻塞的完整恢复会话。
const tick=async()=>{for(let i=0;i<40;i++)await Promise.resolve();};
// 默认拒绝写入并允许外部修改服务基线;存储可注入 quota,支持验证恢复计划先发布后落盘失败的重试。
function fixture(){
let server=original,version=1,image,leader;const calls=[];
const f={reject:true,broken:false,calls,storage:{async claim(scope){if(leader)throw Error('occupied');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(){}},port:{compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions:new Map([['canvas',version]])};},async read(){return{document:server,versions:new Map([['canvas',version]])};}},remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},setServer(document){server=document;version++;},image:()=>structuredClone(image),async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}};
const f={reject:true,broken:false,calls,storage:{
// 拒绝并行 owner,释放后下一次 open 才能重新接管;这是内存互斥替身。
async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},
// 返回最近已提交镜像的副本,重开复用相同存储但不共享可变镜像对象。
async recover(){return image&&structuredClone(image);},
// 故障发生时不推进镜像;成功时校验存储 owner 并返回实际保存修订。
async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},
// 本组不读取本地文件;空方法仅补齐 StoragePort。
async readFile(){},close(){}},port:{
// 根据当前 ACK 版本编译条件请求,让恢复后的替代意图采用新基线。
compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},
// 默认给出明确 not-applied;放行后先检查版本再改服务状态。此替身不实现未知结果或请求去重。
async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},
// 这里不测试未知结果查询;若恢复误走该路径,立即失败。
async query(){throw Error('unexpected query');},
// 为已知成功请求提供当前服务模型和版本。
canonical(){return{document:server,versions:new Map([['canvas',version]])};},
// 恢复会话读取最新完整服务基线,不读取客户端 working。
async read(){return{document:server,versions:new Map([['canvas',version]])};}},
// 外部写入逐字段保留存在性,模拟其他客户端推进远程版本。
remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},
// 外部几何只改变指定轴,恢复时应保留本地未触碰的那些轴。
remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},
// 直接替换服务模型并推进版本,用来模拟目标删除等生命周期变化。
setServer(document){server=document;version++;},
// 提供恢复镜像副本,供审计篡改测试使用。
image:()=>structuredClone(image),
// 重新装配引擎但保留 fixture 的服务端、回执观测和存储状态。
async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}};
return f;
}
// 只收集尚未解决的非上传操作,生成恢复选项时不把独立资源上传当作文档编辑。
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
// 为每个待处理操作显式指定选择;某些用例覆盖个别选择以验证依赖闭包和动作限制。
const choices=(engine,choice='keep-local')=>new Map(pending(engine).map(record=>[record.intent.id,choice]));
// 通过真实命令入口修改标题,可传 groupId 检查输入历史分组在恢复后如何拆分或保留。
const title=(engine,value,settings)=>engine.editor.dispatch({type:'element.data',target,patch:{title:value}},settings);
// keep-local 以远程字段前值创建新操作和新请求 key,保留旧拒绝;撤销回到审阅过的远程标题,未触碰文字不回退。
test('keep-local freezes a new conditional intent/key, retains the old rejection, and undo restores the reviewed remote value',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote',text:'untouched remote'});
const before=engine.getSnapshot(),old=[...before.runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
@@ -34,6 +75,7 @@ test('keep-local freezes a new conditional intent/key, retains the old rejection
await engine.close();
});
// 丢弃中间一步后重连剩余操作依赖和 undo 链;旧历史及恢复前内容仍保存在审计备份。
test('discarding a middle edit preserves the remaining undo chain and archives the old history',async()=>{
const f=fixture(),engine=await f.open();title(engine,'one');await engine.settle();title(engine,'two');title(engine,'three');await engine.settle();f.remote({title:'remote'});
const oldHistory=engine.getSnapshot().history,ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),selected=new Map([[ids[0],'keep-local'],[ids[1],'discard-local'],[ids[2],'keep-local']]),plan=session.plan(selected);
@@ -44,6 +86,7 @@ test('discarding a middle edit preserves the remaining undo chain and archives t
assert.equal(oldHistory.undo.length,3);assert.equal(engine.getSnapshot().runtime.recoveries.get(plan.id).backup.document.elements[0].data.title,'three');await engine.close();
});
// 同一输入组中的已确认前缀仍可撤销;若服务端改过同字段,则撤销基点改为审阅的远程值。
test('typing groups preserve an acknowledged prefix unless the server changed that field',async()=>{
for(const remote of [false,true]){
const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle();
@@ -53,24 +96,28 @@ test('typing groups preserve an acknowledged prefix unless the server changed th
}
});
// 只丢弃输入组的 pending 后缀,不删除已确认前缀的撤销能力。
test('discarding only a pending group suffix retains applied edits and other fields',async()=>{
const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle();
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,1);
await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');await engine.close();
});
// 局部改宽度时保留远程 x 和 rotation;撤销只恢复宽度,不回退远程几何。
test('geometry recovery changes only locally touched axes and undo retains remote geometry',async()=>{
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();f.remoteGeometry({x:300,rotation:45});
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.deepEqual(plan.document.elements.get('a').geometry,{x:300,y:0,width:200,height:100,rotation:45});
f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.deepEqual(engine.getSnapshot().document.elements.get('a').geometry,{x:300,y:0,width:100,height:100,rotation:45});await engine.close();
});
// 远程已删除的目标只能丢弃本地修改,keep-local 不得暗中复活旧 incarnation;恢复保留墓碑。
test('a deleted target may be discarded but cannot be resurrected by keep-local',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.setServer(applyChanges(original,[{kind:'element',id:'a',before:element,after:null}]));
const session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/stale_incarnation/);const plan=session.plan(choices(engine,'discard-local'));
assert.equal(plan.history.undo.length,0);await session.resolve(plan);assert.equal(engine.getSnapshot().document.elements.has('a'),false);assert.equal(engine.getSnapshot().document.tombstones.get('a'),1);await engine.close();
});
// 恢复发布后 quota 失败仍保留同一个计划和发送屏障;不能复制计划对象替代原计划,持久化重试成功后只新增一次写入。
test('recovery publication failure keeps one plan and blocks sends until the exact cut becomes durable',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
f.reject=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);const revision=engine.getSnapshot().rootRevision;assert.equal(f.calls.length,1);
@@ -78,11 +125,13 @@ test('recovery publication failure keeps one plan and blocks sends until the exa
f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close();
});
// 漏选任何待处理操作都会拒绝计划;撤销产生的 traversal 也能进入恢复规划,取消会话不提交该计划。
test('incomplete choices reject while recorded user/traversal recovery can be planned',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();const session=await engine.prepareRecovery();assert.throws(()=>session.plan(new Map()),/recovery_choices_incomplete/);await session.cancel();
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.ok(undoSession.plan(choices(engine)).history.redo.length===1);await undoSession.cancel();await engine.close();
});
// 只退休依赖链一部分会被拒绝;已 dispatch、结果未知的操作即使补全闭包仍不安全,失败必须保持账本原引用。
test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{
const ledger=createLedger(),intent=(id,dependencies=[])=>({id,operationType:'document.change',operationSchemaVersion:1,sequence:id==='a'?1:2,origin:'user',canvasIds:['canvas'],changes:[],dependencies});
ledger.add(intent('a'),{});ledger.add(intent('b',['a']),{});const before=ledger.getSnapshot();assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'}]),/recovery_dependency_closure/);assert.equal(ledger.getSnapshot(),before);
@@ -90,6 +139,7 @@ test('retirement rejects unknown work and incomplete dependency closures without
assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'},{operationId:'b',resolution:'discarded'}]),/recovery_operation_not_safe/);assert.equal(ledger.getSnapshot(),frozen);
});
// 旧 owner 的几何不能直接搬到新坐标系;pending run.start 必须显式丢弃并另发新命令,恢复不能自动重启付费动作。
test('owner changes reject old geometry; pending paid actions require an explicit discard and never auto-restart',async()=>{
const {planConflictRecovery}=await import('../../src/lib/canvas-runtime/conflicts.ts');
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();
@@ -100,6 +150,7 @@ test('owner changes reject old geometry; pending paid actions require an explici
assert.equal(plan.ledger.operations.get('paid-start').resolution,'discarded');assert.ok(plan.operationIds.every(id=>plan.operations.get(id).operationType==='document.change'));await session.cancel();await engine.close();
});
// 已发布计划即使脱离原会话也可完成持久化屏障;重开校验审计、资源 pin、替代操作链接和备份历史,任一篡改都拒绝。
test('recovery audits and replacement links are validated when reopening and the owner can finish a detached durable barrier',async()=>{
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
f.broken=true;await assert.rejects(session.resolve(plan),/quota/);f.broken=false;f.reject=false;await engine.cancelRecovery();await engine.settle();
@@ -114,18 +165,21 @@ test('recovery audits and replacement links are validated when reopening and the
await engine.close();
});
// 远程已满足本地目标值时以 satisfied 退休旧操作,不发送新请求或新增 undo,也不把原拒绝改写成服务端成功。
test('a remote value already satisfying the local choice retires the intent without claiming a server write or adding undo',async()=>{
const f=fixture(),engine=await f.open();title(engine,'same');await engine.settle();f.remote({title:'same'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);
assert.equal(f.calls.length,1);assert.equal([...engine.getSnapshot().runtime.ledger.requests.values()][0].outcome,'not-applied');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,1);await engine.close();
});
// 混合输入组丢弃待处理标题后,已确认文字仍可撤销;撤销不得覆盖远程标题。
test('discarding a field in a mixed typing group keeps the unrelated acknowledged field undoable',async()=>{
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target,patch:{text:'acknowledged text'}},{groupId:'mixed'});await engine.settle();f.reject=true;title(engine,'pending title',{groupId:'mixed'});await engine.settle();f.remote({title:'remote title'});
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.history.undo.length,1);await session.resolve(plan);f.reject=false;
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote title');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'');await engine.close();
});
// 取消挂起读取会 abort 且拒绝迟到结果;即使计划全是 discard-local,提交时仍须重新检查当前身份策略。
test('discard-only resolution rechecks current identity and a cancelled read cannot later create a session',async()=>{
const f=fixture();let allowed=true,release,signal;const engine=await f.open();title(engine,'local');await engine.settle();
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));await session.cancel();
@@ -136,6 +190,7 @@ test('discard-only resolution rechecks current identity and a cancelled read can
});
// 较早拒绝编辑的 keep/discard 不丢失后来本地抵消的创建删除历史;恢复保留本地墓碑,撤销用新 incarnation,不把抵消操作塞进请求。
test('later locally elided history survives keep/discard of an earlier rejected edit and its recovery replay audit',async()=>{
for(const keep of [false,true]){
const f=fixture();let engine=await f.open();
+14
View File
@@ -1,22 +1,36 @@
/**
* 连线绘制溢出与缓存回归。端点矩形不足以代表曲线范围,索引需包含控制多边形和描边;缺锚点、非法响应或预算不足必须unknown。
* 使用服务回调注入锚点/曲线/线宽,测试路径不依赖真实DOM测量或浏览器SVG绘制。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
import {projectedConnectionPaintOverflow} from '../../src/lib/canvas-bindings/connection-paint.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 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);});
// 直线全被端点范围覆盖时无额外区域;缺/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']);});
// 边或段预算超限用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));});
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);});
// 曲线已缓存也要计入新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);});
// 仅投影预览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);});
// 无边时代理禁止遍历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);}});
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',()=>{
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}};}};
+13
View File
@@ -1,3 +1,7 @@
/**
* 复制快照编解码的协作式预算测试。调度只影响让出时机,不能影响快照字节、墓碑、索引读取或取消语义。
* 同时覆盖普通/索引快照及旧 prepared duplicate 源格式;假时钟与工作量上限分别控制耗时和粗时钟场景。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
@@ -5,29 +9,38 @@ import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtim
import {encodeIndexedCopySnapshot,openIndexedCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot-index.ts';
import {encodeDuplicateSource} from '../../src/lib/canvas-runtime/import-duplicate.ts';
import {createCopyBudget} from '../../src/lib/canvas-runtime/copy-budget.ts';
// 内置定义和独立 AbortSignal 工厂,避免一个场景的取消状态传入另一个场景。
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
// 包含中文、emoji、转义文本的节点工厂,让字节一致性比较覆盖多字节编码。
const node=i=>({id:`n-${i}`,incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:`卡片 ${i}`,text:'emoji 👀\n"quoted"'},geometry:{x:i,y:0,width:100,height:100,rotation:0}});
// 512 个节点及一个墓碑组成统一快照,既覆盖批量处理,也覆盖删除身份信息。
const document=createDocument('project',[{id:'canvas',parent:null}],Array.from({length:512},(_,i)=>node(i)),new Map([['retired',2]]));
// cheap 时钟固定为零,costly 每次前进 10;记录 yield 次数,区分按预算让出与固定条数休眠。
function scheduling(slow=false){let yields=0,tick=0;return{options:{now:()=>slow?(++tick)*10:0,yieldTask:async()=>{yields++;}},get yields(){return yields;}};}
// 普通/索引格式在便宜和昂贵时间切片下编码完全相同;解码保留 512 节点及墓碑,索引格式还读取末项核对内容。
test('copy snapshot bytes and decoded records are invariant across cheap and expensive scheduling slices',async()=>{
for(const [encode,decode,indexed] of [[encodeCopySnapshot,decodeCopySnapshot,false],[encodeIndexedCopySnapshot,openIndexedCopySnapshot,true]]){
const cheap=scheduling(),costly=scheduling(true),a=await encode(document,signal(),cheap.options),b=await encode(document,signal(),costly.options);assert.equal(await a.text(),await b.text());assert.equal(cheap.yields,0,'cheap work slept at a fixed record count');assert.ok(costly.yields>0);const before=costly.yields,snapshot=await decode(b,'project',registry,signal(),costly.options),restored=indexed?snapshot.document:snapshot;assert.equal(restored.elements.size,512);assert.deepEqual([...restored.tombstones],[['retired',2]]);assert.ok(costly.yields>before);if(indexed)assert.deepEqual(await snapshot.readElement('n-511',signal()),document.elements.get('n-511'));else assert.deepEqual([...restored.elements],[...document.elements]);
}
});
// 编码、普通解码及打开索引快照各自在第一次 yield 中取消;必须报 copy_aborted,不能返回可用的半成品,原文档不变。
test('abort at a scheduled yield prevents copy encoders and readers from returning a partial snapshot',async()=>{
const old=await encodeCopySnapshot(document,signal(),scheduling().options),indexed=await encodeIndexedCopySnapshot(document,signal(),scheduling().options);
for(const run of [(s,o)=>encodeCopySnapshot(document,s,o),(s,o)=>encodeIndexedCopySnapshot(document,s,o),(s,o)=>decodeCopySnapshot(old,'project',registry,s,o),(s,o)=>openIndexedCopySnapshot(indexed,'project',registry,s,o)]){const controller=new AbortController();let time=0,yields=0;await assert.rejects(run(controller.signal,{now:()=>time+=10,yieldTask:async()=>{yields++;controller.abort();}}),error=>error.code==='copy_aborted');assert.equal(yields,1);}
assert.equal(document.elements.size,512);assert.equal(document.elements.get('n-0').data.title,'卡片 0');
});
// 时钟不动时仍按调用者累计的工作量上限让出,避免大记录霸占主线程;取消后 step 及预先取消的预算创建都立即拒绝。
test('large records consume work budget and coarse clocks still yield without changing copy cancellation errors',async()=>{
const controller=new AbortController();let yields=0;const budget=createCopyBudget(controller.signal,{now:()=>0,yieldTask:async()=>{yields++;}});for(let i=0;i<64;i++)await budget.step(256);assert.ok(yields>0,'coarse clock allowed an unbounded byte workload');controller.abort();assert.throws(()=>budget.step(),error=>error.code==='copy_aborted');
const aborted=new AbortController();aborted.abort();assert.throws(()=>createCopyBudget(aborted.signal),error=>error.code==='copy_aborted');
});
// 拒绝非正或非有限 frameBudgetMs;yieldTask 的宿主错误保留原对象,不转换成伪造的复制取消。
test('copy budget rejects invalid scheduling and preserves host scheduling failures',async()=>{
for(const frameBudgetMs of [0,-1,NaN,Infinity])await assert.rejects(encodeIndexedCopySnapshot(document,signal(),{frameBudgetMs}),error=>error.code==='invalid_copy_budget');
const failure=Error('host unavailable');let time=0;await assert.rejects(encodeCopySnapshot(document,signal(),{now:()=>time+=10,yieldTask:async()=>{throw failure;}}),error=>error===failure);
});
// 旧式 prepared duplicate 源在不同切片下保持相同字节;预算边界取消时拒绝而非返回部分输出。
test('legacy prepared duplicate sources preserve exact bytes and abort before returning on a budget boundary',async()=>{
let id=0;const plan=planDuplicate(document,registry,[...document.elements.values()].map(({id,incarnation})=>({id,incarnation})),{createId:()=>`copy-${++id}`}),cheap=scheduling(),costly=scheduling(true),a=await encodeDuplicateSource(document,registry,plan,signal(),cheap.options),b=await encodeDuplicateSource(document,registry,plan,signal(),costly.options);assert.equal(await a.text(),await b.text());assert.equal(cheap.yields,0);assert.ok(costly.yields>0);const controller=new AbortController();let time=0;await assert.rejects(encodeDuplicateSource(document,registry,plan,controller.signal,{now:()=>time+=10,yieldTask:async()=>controller.abort()}),error=>error.code==='copy_aborted');
});
@@ -1,11 +1,18 @@
/**
* 持久复制源的引用依赖与批次选择测试。映射必须和声明的引用路径使用同一遍历规则,不能仍指向原来的子对象。
* 外部保留引用还需校验目标生命周期;循环依赖要作为完整强连通组加入,不能靠拆边绕过依赖或容量。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,planDuplicate,defineComponent} from '../../src/lib/canvas-core/index.ts';
import {encodeDuplicateSource,prepareDuplicateImport,assertDuplicateExternal} from '../../src/lib/canvas-runtime/import-duplicate.ts';
import {selectImportGroups} from '../../src/lib/canvas-runtime/import-groups.ts';
// 可指定类型/归属/数据的第一代节点样本,用于 frame 端口及自定义对象数组引用。
const node=(id,owner='P',componentId='text',data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'',...data},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
// 使用内置复制 schema,frame.ports 中的 nodeId 被识别为元素引用,端口局部 ID 仍是普通数据。
const registry=createRegistry(builtinComponents());
// 复制 frame 的持久源须声明子节点依赖并使用新 nodeId;篡改为旧引用或删掉依赖都在导入准备时拒绝。
test('durable copy source includes mapped port dependencies and refuses an old source still targeting original children',async()=>{
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame',{ports:[{nodeId:'A',id:'in',direction:'input',handle:'left'}]}),node('A','S')]);let next=0;
const plan=planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=>`new-${++next}`}),source=await encodeDuplicateSource(document,registry,plan,new AbortController().signal),rows=(await source.text()).trim().split('\n').map(row=>JSON.parse(row)),value=rows.find(row=>row.key==='element:F');
@@ -16,6 +23,7 @@ test('durable copy source includes mapped port dependencies and refuses an old s
const missing=structuredClone(value);missing.dependencies=missing.dependencies.filter(key=>key!=='element:A');assert.throws(()=>prepareDuplicateImport({value:missing},job,key=>stableIds[key],registry),/invalid_duplicate_source/);
});
// 自定义 rows.*.node 外部引用保持原目标并记录 incarnation;目标后来换代,晚期 guard 必须拒绝旧引用。
test('object-array external references share the same traversal for mapping and late lifecycle guards',async()=>{
const linked=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['rows','*','node'],target:'element',external:'preserve'}],omit:[]}}),r=createRegistry([...builtinComponents(),linked]),document=createDocument('P',[{id:'P',parent:null}],[node('C'),node('L','P','linked',{rows:[{node:'C',literal:'C'}]})]);
const plan=planDuplicate(document,r,[{id:'L',incarnation:1}],{createId:()=> 'new-L'}),rows=(await(await encodeDuplicateSource(document,r,plan,new AbortController().signal)).text()).trim().split('\n').map(row=>JSON.parse(row)),job={source:{kind:'duplicate-ndjson'},stableIds:{'element:L':'new-L'}};
@@ -23,7 +31,9 @@ test('object-array external references share the same traversal for mapping and
const changed=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(row=>row.id==='C'?{...row,incarnation:2}:row));assert.throws(()=>assertDuplicateExternal({value:rows[0]},job,changed,r),/copy_external_changed/);
});
// 构造待加入记录的位置描述:依赖键、编码字节数及变化数供 SCC 分组和硬容量检查。
const locator=(key,dependencies,byteLength=100)=>({key,dependencies,byteLength,changeCount:1});
// A/B 循环还依赖 outside:外部未完成时不选,已完成时整组可超过软 units=1,但字节或 changes 硬上限不足应抛错。
test('SCC selection waits for missing external dependencies and enforces hard group capacity',()=>{
const pending=[locator('A',['B']),locator('B',['A','outside'])];assert.deepEqual(selectImportGroups(pending,new Set(),{units:1,bytes:1000,changes:100}),[]);
assert.deepEqual(selectImportGroups(pending,new Set(['outside']),{units:1,bytes:1000,changes:100}).flat().map(row=>row.key),['A','B']);
@@ -1,3 +1,7 @@
/**
* 索引复制快照的拓扑/正文分离测试:先读足够规划的结构信息,再按位置读取需要的完整元素。
* 拓扑视图不是完整可编辑文档;未知正文错误可以延后到对应记录请求时,但定位、几何与格式边界必须先检查。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,planDuplicate,validateDocument} from '../../src/lib/canvas-core/index.ts';
@@ -5,10 +9,14 @@ import {encodeIndexedCopySnapshot,openIndexedCopySnapshot} from '../../src/lib/c
import {prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts';
import {copyRegistrySignature,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
import {createDuplicatePlanSource} from '../../src/lib/canvas-runtime/duplicate-source.ts';
// 构造含私密标记文本和 presentation 扩展的节点,确保索引阶段没有悄悄解码或保留完整正文。
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal,node=(id,owner='P',componentId='text')=>({id,incarnation:2,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'unparsed-payload-秘密',extra:{retained:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}});
// 带端口、旋转/缩放子分区、外部入边及墓碑的基线,使懒读取结果可与同步深复制逐项比较。
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{...node('F').data,ports:[{id:'port',nodeId:'A'}],presentation:{unitScale:2,contentScale:.75,other:'not-needed-in-index'}},geometry:{...node('F').geometry,rotation:35}},node('A','S','image'),{id:'edge',incarnation:1,structure:'connection',ownerCanvasId:'P',componentId:'connection',dataVersion:1,data:{retained:true},source:{elementId:'C'},target:{elementId:'A'},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:70,y:80},in:{x:1,y:2},out:{x:3,y:4}}]}}],new Map([['deleted',4]]));
// 以 NDJSON 行拆分快照,供定位索引和正文的定点损坏注入,不改变正常编码器。
const lines=async bytes=>(await bytes.text()).trimEnd().split('\n');
// 索引只保留拓扑所需单位信息,不含正文或 ports;按需只读 edge 也能生成正确边,全部记录最终与同步计划一致。
test('indexed snapshots read topology before payload, preserve raw records on demand and reproduce exact mapped output',async()=>{
const bytes=await encodeIndexedCopySnapshot(document,signal()),snapshot=await openIndexedCopySnapshot(bytes,'project',registry,signal());assert.equal(JSON.stringify([...snapshot.document.elements]).includes('unparsed-payload'),false);assert.equal(snapshot.document.elements.get('F').data.ports,undefined);assert.deepEqual(snapshot.document.elements.get('F').data.presentation,{unitScale:2,contentScale:.75});assert.deepEqual([...snapshot.document.tombstones],[...document.tombstones]);assert.throws(()=>validateDocument(snapshot.document,registry));
const loaded=[];let next=0;const state=await prepareDuplicateStream(snapshot.document,registry,[{id:'F',incarnation:2}],{createId:()=>`copy-${++next}`},{sourceToken:'a'.repeat(64),schemaToken:copyRegistrySignature(snapshot.document,registry),topologyOnly:true});assert.equal(loaded.length,0);const source=createDuplicatePlanSource(snapshot.document,registry,state,'indexed',async(id,abort)=>{loaded.push(id);return snapshot.readElement(id,abort);});let reference=0;const expected=planDuplicate(document,registry,[{id:'F',incarnation:2}],{createId:()=>`copy-${++reference}`});
@@ -16,19 +24,23 @@ test('indexed snapshots read topology before payload, preserve raw records on de
for(let i=0;i<expected.changes.length;i++)assert.deepEqual((await source.read(i,signal())).value.change,expected.changes[i]);for(const[id,record]of document.elements)assert.deepEqual(await snapshot.readElement(id,signal()),record);await assert.rejects(decodeCopySnapshot(bytes,'project',registry,signal()),/invalid_copy_snapshot/);
});
// 损坏尾部 edge 正文不妨碍打开索引及读取 A;请求损坏记录或正文 ID 与索引不符时才明确拒绝。
test('index decoding defers corrupt or invalid component content until the corresponding record is requested',async()=>{
const rows=await lines(await encodeIndexedCopySnapshot(document,signal())),last=rows.length-1;rows[last]='!'+rows[last].slice(1);const corrupt=new Blob([rows.join('\n')+'\n']),snapshot=await openIndexedCopySnapshot(corrupt,'project',registry,signal());assert.equal((await snapshot.readElement('A',signal())).id,'A');await assert.rejects(snapshot.readElement('edge',signal()),/invalid_copy_snapshot/);
const clean=await lines(await encodeIndexedCopySnapshot(document,signal())),i=clean.findIndex(row=>row.startsWith('{"kind":"element","record":{"id":"A"'));clean[i]=clean[i].replace('"id":"A"','"id":"Z"');const mismatch=await openIndexedCopySnapshot(new Blob([clean.join('\n')+'\n']),'project',registry,signal());await assert.rejects(mismatch.readElement('A',signal()),/invalid_copy_snapshot/);
});
// 索引偏移空洞、零长度、非法几何、截断/追加字节、项目不符与各阶段取消都拒绝,不能返回貌似可用的源。
test('indexed source rejects locator gaps, geometry corruption, truncation, foreign versions and aborted reads',async()=>{
const bytes=await encodeIndexedCopySnapshot(document,signal()),rows=await lines(bytes),index=rows.findIndex(row=>JSON.parse(row).kind==='element-index');
for(const change of [row=>({...row,offset:1}),row=>({...row,byteLength:0}),row=>({...row,record:{...row.record,geometry:{...row.record.geometry,width:0}}})]){const bad=[...rows];bad[index]=JSON.stringify(change(JSON.parse(bad[index])));await assert.rejects(openIndexedCopySnapshot(new Blob([bad.join('\n')+'\n']),'project',registry,signal()));}
await assert.rejects(openIndexedCopySnapshot(bytes.slice(0,bytes.size-1),'project',registry,signal()));await assert.rejects(openIndexedCopySnapshot(new Blob([await bytes.text(),'x']),'project',registry,signal()));await assert.rejects(openIndexedCopySnapshot(bytes,'foreign',registry,signal()));const controller=new AbortController();controller.abort();await assert.rejects(encodeIndexedCopySnapshot(document,controller.signal),/copy_aborted/);await assert.rejects(openIndexedCopySnapshot(bytes,'project',registry,controller.signal),/copy_aborted/);const snapshot=await openIndexedCopySnapshot(bytes,'project',registry,signal());await assert.rejects(snapshot.readElement('A',controller.signal),/copy_aborted/);
});
// 连续读取 120 条大记录应复用有限原始字节窗口而非每条 slice;再读首项需重新读块,证明旧字节窗口已淘汰。
test('bounded raw windows preserve sequential parser batching and evict old bytes without caching decoded content',async()=>{
const source=createDocument('project',[{id:'P',parent:null}],Array.from({length:120},(_,i)=>({...node(`record-${i}`),data:{title:`record-${i}`,text:'x'.repeat(3500)}}))),bytes=await encodeIndexedCopySnapshot(source,signal()),reads=[];
// 记录实际 Blob.slice 次数,检查连续读记录时有限原始字节窗口的复用和旧窗口淘汰。
class ObservedBlob extends Blob{slice(...args){reads.push(args);return super.slice(...args);}}
const snapshot=await openIndexedCopySnapshot(new ObservedBlob([bytes]),'project',registry,signal());reads.length=0;
for(let i=0;i<120;i++)assert.equal((await snapshot.readElement(`record-${i}`,signal())).data.title,`record-${i}`);assert.ok(reads.length>=5&&reads.length<120/4,`unexpected per-record Blob reads: ${reads.length}`);const count=reads.length;assert.equal((await snapshot.readElement('record-0',signal())).data.text.length,3500);assert.equal(reads.length,count+1,'first window did not leave the bounded cache');
+9
View File
@@ -1,18 +1,27 @@
/**
* 完整复制快照及准备期任务检查点测试。正文、canvas、墓碑和未知字段需完整往返,并与来源项目绑定。
* 准备期尚未发布内容,任务不能同时带运行游标或 committedSourceKeys;格式/解释器不匹配必须拒绝。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
import {createImportJob,validateImportJob,createImportRunner} from '../../src/lib/canvas-runtime/imports.ts';
// 第二代节点、未知嵌套数据与第三代墓碑组成基线,避免只验证最简单创建对象的往返。
const registry=createRegistry(builtinComponents()),node={id:'A',incarnation:2,structure:'node',ownerCanvasId:'P',componentId:'text',dataVersion:1,data:{title:'原始',text:'内容',unknown:{preserved:true}},geometry:{x:0,y:0,width:100,height:100,rotation:0}},document=createDocument('project',[{id:'P',parent:null}],[node],new Map([['deleted',3]]));
// 每次创建独立未取消信号;取消场景显式提供已 abort 的 controller。
const signal=()=>new AbortController().signal;
// 完整快照往返保持元素/canvas/墓碑及未知数据,输出元素数据冻结;错误项目 ID 拒绝。
test('copy snapshot roundtrip preserves the complete source, identity lifecycles and unknown data',async()=>{
const bytes=await encodeCopySnapshot(document,signal()),decoded=await decodeCopySnapshot(bytes,'project',registry,signal());assert.deepEqual([...decoded.elements],[...document.elements]);assert.deepEqual([...decoded.canvases],[...document.canvases]);assert.deepEqual([...decoded.tombstones],[...document.tombstones]);assert.ok(Object.isFrozen(decoded.elements.get('A').data));await assert.rejects(decodeCopySnapshot(bytes,'different-project',registry,signal()),/invalid_copy_snapshot/);
});
// 空值、错误 JSON、未来版本、未知行、重复记录和取消都不能生成文档;已取消编码也必须立即拒绝。
test('snapshot decoding rejects malformed, future, duplicate, invalid and truncated sources without inventing a document',async()=>{
for(const text of ['','{}','{bad}',JSON.stringify({kind:'header',version:2,projectId:'project'}),JSON.stringify({kind:'header',version:1,projectId:'project'})+'\n'+JSON.stringify({kind:'other',record:{}})])await assert.rejects(decodeCopySnapshot(new Blob([text]),'project',registry,signal()));
const original=await(await encodeCopySnapshot(document,signal())).text(),row=original.trim().split('\n').at(-1);await assert.rejects(decodeCopySnapshot(new Blob([original+row]),'project',registry,signal()),/invalid_copy_snapshot/);
const controller=new AbortController();controller.abort();await assert.rejects(encodeCopySnapshot(document,controller.signal),/copy_aborted/);await assert.rejects(decodeCopySnapshot(new Blob([original]),'project',registry,controller.signal),/copy_aborted/);
});
// 准备检查点需有效选择、解释器版本、唯一已分配 ID 且没有发布进度;不具备对应解释器的 runner 不能运行。
test('preparation checkpoints require a matching interpreter, valid selection, unique allocations and no published progress',()=>{
const base={...createImportJob({id:'job',projectId:'project',sourceFingerprint:'snapshot',parserVersion:1,source:{kind:'duplicate-snapshot',sha256:'a'.repeat(64),fileId:'file',byteLength:10,targetCanvasId:'P',copy:{sourceRevision:0,roots:[]}}}),status:'preparing',preparation:{version:1,registrySignature:'registry-v1',selection:[{id:'A',incarnation:2}],worldOffset:{x:32,y:32},allocationIds:['new-A'],steps:512}};validateImportJob(base);
for(const patch of [{preparation:undefined},{preparation:{...base.preparation,version:5}},{preparation:{...base.preparation,allocationIds:['same','same']}},{preparation:{...base.preparation,selection:[]}},{cursor:1},{committedSourceKeys:['element:A']},{status:'running'},{source:{...base.source,snapshotVersion:3}},{source:{...base.source,snapshotVersion:2}}])assert.throws(()=>validateImportJob({...base,...patch}));
+32
View File
@@ -1,15 +1,24 @@
/**
* 核心编辑器的组合回归测试。用内存文档与内置/自定义registry验证事务发布、history、组件兼容和临时手势,不连接服务端。
* 多数失败断言同时检查根对象身份,确保“拒绝”不仅返回错误,还没有发布中间文档/历史/运行元数据。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDocument, createEditor, createRegistry, builtinComponents, defineComponent, defineFeature, editable, movable, resizable, rotatable, migrateElement, commonCanvas } from '../../src/lib/canvas-core/index.ts';
/** 共用合法矩形几何,节点构造器复用该输入;createDocument/editor负责取得自己的不可变数据。 */
const geometry = { x: 10, y: 20, width: 100, height: 80, rotation: 0 };
/** 默认text节点在root,可替换owner/component构造子图或未知类型。 */
const node = (id, ownerCanvasId = 'root', componentId = 'text') => ({ id, incarnation: 1, ownerCanvasId, componentId, dataVersion: 1, structure: 'node', geometry, data: { title: id, text: '' } });
/** 固定第一生命周期目标;删除恢复后继续使用它应被拒绝。 */
const target = id => ({ id, incarnation: 1 });
/** 每个用例隔离editor并使用确定性操作ID;options可覆盖registry/document/policy等依赖。 */
function setup(elements = [node('a'), node('b')], options = {}) {
let next = 0;
return createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], elements), registry: createRegistry(builtinComponents()), createId: () => `op-${++next}`, ...options });
}
// 让reduceRuntime在特定标题下抛错,检查根完全不变;正常提交后区分rootRevision与文档revision,旧修订号更新须拒绝。
test('runtime preparation shares the document publication boundary; metadata has a separate root revision', () => {
const editor = setup(undefined, { runtime: Object.freeze({ ids: [] }), reduceRuntime(previous, next, commit) {
if (next.document.elements.get('a').data.title === 'reject-ledger') throw new Error('ledger_failed');
@@ -29,6 +38,7 @@ test('runtime preparation shares the document publication boundary; metadata has
assert.equal(editor.getSnapshot().operations.size, 1); assert.equal(editor.getSnapshot().history.undo.length, 1);
});
// 事务首条合法、第二条目标缺失:订阅计数与根身份共同证明没有泄露首条命令的部分结果。
test('a rejected second command publishes no document, history, operation or notification', () => {
const editor = setup(), before = editor.getSnapshot(); let notifications = 0;
editor.subscribe(() => notifications++);
@@ -39,6 +49,7 @@ test('a rejected second command publishes no document, history, operation or not
assert.equal(result.status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); assert.equal(notifications, 0);
});
// frame允许移动而不允许旋转;can只查能力,不消耗createId,也不发布根。
test('can is pure and command handlers come from the component capability table', () => {
let ids = 0;
const frame = node('a', 'root', 'frame'), editor = setup([frame], { createId: () => String(++ids) });
@@ -48,6 +59,7 @@ test('can is pure and command handlers come from the component capability table'
assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before);
});
// 跨父子端点的边归root,操作覆盖两canvas;带函数的非JSON元素包络必须拒绝,不能序列化时悄悄丢字段。
test('connection creation guards endpoint canvases and non-JSON envelopes reject without silent truncation',()=>{
const document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'f'}}],[node('a'),node('f','root','frame'),node('b','child')]);
const editor=createEditor({registry:createRegistry(builtinComponents()),document});
@@ -56,6 +68,7 @@ test('connection creation guards endpoint canvases and non-JSON envelopes reject
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.create',element:{...node('invalid'),unserializable:()=>42}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);
});
// 未知组件的constructor/__proto__原样保留,但对可编辑对象注入危险字段被拒绝,并核查原型未污染。
test('opaque JSON keys survive unrelated edits while unsafe field setters remain rejected',()=>{
const future={...node('opaque'),componentId:'future.plugin',dataVersion:99,data:JSON.parse('{"constructor":{"retain":true},"__proto__":{"ordinary":"json data"}}')};
const editor=setup([node('a'),future]);editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});
@@ -63,6 +76,7 @@ test('opaque JSON keys survive unrelated edits while unsafe field setters remain
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:JSON.parse('{"__proto__":{"polluted":true}}')}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(Object.prototype.polluted,undefined);
});
// 先写null再undo/redo检查字段存在性,随后删除选中节点并dispose会话,验证选择/视图清理。
test('absent and explicit null are distinct history values; view selection follows element deletion', async () => {
const editor = setup();
editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } });
@@ -76,6 +90,7 @@ test('absent and explicit null are distinct history values; view selection follo
assert.equal(sessions.getSnapshot().views.size, 0);
});
// 动态policy先允许预览,再通过runtime发布触发权限重查;取消原因和下一次begin都必须反映forbidden。
test('permission changes cancel geometry previews and prevent new gestures', async () => {
let permitted = true;
const editor = setup(undefined, { policy: () => permitted ? { enabled: true } : { enabled: false, code: 'forbidden', targets: ['a'] } });
@@ -88,6 +103,7 @@ test('permission changes cancel geometry previews and prevent new gestures', asy
assert.equal(sessions.begin('view', [target('a')]).code, 'forbidden'); sessions.dispose();
});
// 把标题和位置放进一个事务,再注入不记历史的资源ACK;undo/redo只能改原编辑字段,不能抹掉资源结果。
test('several commands are one history unit; data undo preserves a later resource acknowledgment', () => {
const editor = setup();
editor.transaction(tx => {
@@ -103,6 +119,7 @@ test('several commands are one history unit; data undo preserves a later resourc
assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored-asset');
});
// 远端后来改同一标题,撤销本地旧值必须拒绝,并保持当前根和栈不变。
test('same-field conflict does not pop history or overwrite remote edits', () => {
const editor = setup();
editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'local' } });
@@ -111,6 +128,7 @@ test('same-field conflict does not pop history or overwrite remote edits', () =>
assert.equal(editor.history.undo().status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before);
});
// 删除连带移除边,恢复带回边且incarnation递增;旧目标命令不能修改恢复后的新生命周期。
test('delete undo restores a new incarnation and the original connections; stale targets remain invalid', () => {
const edge = { id: 'e', incarnation: 1, ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, structure: 'connection', source: { elementId: 'a' }, target: { elementId: 'b' }, path: null };
const editor = setup([node('a'), node('b'), edge]);
@@ -125,6 +143,7 @@ test('delete undo restores a new incarnation and the original connections; stale
assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 3);
});
// historyLimit=2检查淘汰;撤销后新分支清redo,同值编辑保持根身份,避免空操作占历史。
test('new user edit clears redo, bounded history evicts old entries, no-op creates no operation', () => {
const editor = setup(undefined, { historyLimit: 2 });
for (const title of ['one', 'two', 'three']) editor.dispatch({ type: 'element.data', target: target('a'), patch: { title } });
@@ -137,6 +156,7 @@ test('new user edit clears redo, bounded history evicts old entries, no-op creat
assert.strictEqual(editor.getSnapshot(), before);
});
// 异步事务在await前后都有写入尝试;等待微任务后根仍不变,捕获的tx也已关闭。
test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => {
const editor = setup(), before = editor.getSnapshot(); let captured;
const result = editor.transaction(async tx => {
@@ -150,6 +170,7 @@ test('async transactions reject, close their draft, and never publish the asynch
assert.throws(() => captured.dispatch({ type: 'element.remove', target: target('a') }), /transaction_closed/);
});
// 编辑已知节点不改未知记录;未知组件自身无法借用未安装行为写入。
test('unknown and future components survive known-object editing but cannot use uninstalled behavior', () => {
const unknown = { ...node('future', 'root', 'future/audio'), dataVersion: 99, data: { novel: ['keep', { every: 'field' }] } };
const editor = setup([node('a'), unknown]);
@@ -158,6 +179,7 @@ test('unknown and future components survive known-object editing but cannot use
assert.equal(editor.dispatch({ type: 'element.data', target: target('future'), patch: { title: 'overwrite' } }).status, 'rejected');
});
// 重复feature在registry装配时报错;自定义aspect缩放仅改width也应计算相应height。
test('capability conflicts are configuration errors; custom resize handlers can use a different constraint', () => {
assert.throws(() => createRegistry([defineComponent({ id: 'bad', dataVersion: 1, structure: 'node', validate: () => true, features: [movable(), movable()] })]), /duplicate_feature/);
const registry = createRegistry([defineComponent({ id: 'ratio', dataVersion: 1, structure: 'node', validate: () => true, features: [resizable({ aspect: true })] })]);
@@ -166,6 +188,7 @@ test('capability conflicts are configuration errors; custom resize handlers can
assert.equal(editor.getSnapshot().document.elements.get('a').geometry.height, 200);
});
// 从v1 title迁为v2 heading,身份/owner稳定且原对象版本仍为1,证明迁移处理副本。
test('migration validates a staged copy and retains stable identity and ownership', () => {
const registry = createRegistry([defineComponent({ id: 'text', dataVersion: 2, structure: 'node', features: [editable()], validate: data => typeof data.heading === 'string', migrations: { 1: data => ({ heading: data.title }) } })]);
const original = node('a');
@@ -174,6 +197,7 @@ test('migration validates a staged copy and retains stable identity and ownershi
assert.equal(original.dataVersion, 1);
});
// 分别构造重复component与不同feature抢同能力,确认配置拒绝;成功装配表也不暴露set。
test('distinct features cannot install the same capability or overwrite an existing component',()=>{
const handler=()=>[],a=defineFeature('first',{'content.edit':handler}),b=defineFeature('second',{'content.edit':handler});
const definition=defineComponent({id:'plugin',dataVersion:1,structure:'node',validate:()=>true,features:[a]});
@@ -183,6 +207,7 @@ test('distinct features cannot install the same capability or overwrite an exist
assert.equal(registry.components.set,undefined);assert.equal(registry.handlers.get('plugin').set,undefined);
});
// 遍历缺步骤、抛错、修改冻结输入和非法JSON输出;检查原始值、根、索引及通知全未变,并覆盖未来版本透传与getter只读一次。
test('failed schema or reference migrations retain the original data, version and identity without partial publication',()=>{
const original={...node('a'),data:{title:'Original',links:['b'],nested:{retain:true}}},saved=structuredClone(original);
for(const migrations of[
@@ -207,6 +232,7 @@ test('failed schema or reference migrations retain the original data, version an
const migrated=migrateElement(original,captured);assert.equal(reads,1);assert.equal(migrated.data.heading,'Captured once');assert.deepEqual(original,saved);assert.ok(Object.isFrozen(migrated.data));
});
// 事务内部尝试dispatch/transaction/undo/runtime/reconcile,全部重入拒绝;外层空事务也不得触发reduceRuntime或通知。
test('reentrant mutation entry points reject without publishing document, history, runtime, indexes or operations',()=>{
for(const attempt of[
editor=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'nested'}}),
@@ -221,6 +247,7 @@ test('reentrant mutation entry points reject without publishing document, histor
}
});
// 两兄弟canvas共同上级为root;把root挂到其后代会成环,须拒绝且保持根不变。
test('cross-child connection belongs to the common ancestor; cyclic canvas ownership rejects atomically', () => {
const elements = [node('f'), node('g'), node('a', 'child'), node('b', 'sibling')];
const canvases = [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'f' } }, { id: 'sibling', parent: { canvasId: 'root', entryElementId: 'g' } }];
@@ -232,6 +259,7 @@ test('cross-child connection belongs to the common ancestor; cyclic canvas owner
assert.strictEqual(editor.getSnapshot(), before);
});
// 快照集合无set,订阅者抛错也不能回滚已发布的合法提交;观察通知与提交成功边界分开。
test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => {
const editor = setup();
assert.equal(editor.getSnapshot().document.elements.set, undefined);
@@ -240,6 +268,7 @@ test('snapshot collections expose no mutation methods and observer failures do n
assert.equal(editor.getSnapshot().document.elements.get('a').data.title, 'committed');
});
// 连续预览不改根;期间资源ACK正常合入,取消只撤预览。第二场手势提交才产生一个history条目。
test('gesture preview is transient, cancel preserves an upload ACK, commit creates one history entry', async () => {
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
@@ -257,6 +286,7 @@ test('gesture preview is transient, cancel preserves an upload ACK, commit creat
detach(); sessions.dispose();
});
// 远端几何覆盖导致预览取消;之后detach再次取消手势,文档保持远端已提交位置。
test('geometry conflict and view detach cancel only that gesture, without deleting model content', async () => {
const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts');
const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view');
@@ -268,12 +298,14 @@ test('geometry conflict and view detach cancel only that gesture, without deleti
sessions.dispose();
});
// prepareIntent伪造操作ID并清changes,必须在发布前拒绝,不允许传输准备重写业务事实。
test('transport preparation cannot rewrite a committed document change or its identity',()=>{
const editor=setup(undefined,{prepareIntent:intent=>({...intent,id:'another-operation',changes:[]})});
const before=editor.getSnapshot(),result=editor.dispatch({type:'element.data',target:target('a'),patch:{text:'must not publish'}});
assert.equal(result.code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
});
// 300次同组修改形成大历史,假时钟迫使编码让步;编码期间再编辑,旧镜像仍只含捕获的300项,当前undo仍正确。
test('growing grouped history can yield during recovery encoding without altering undo data',async()=>{
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const editor=setup();
+18 -4
View File
@@ -1,3 +1,7 @@
/**
* 从真实引擎持久化序列提取各个崩溃切点,再用匹配的服务端状态重新打开。
* 比较文档、history、ledger、请求次数和冻结字节;这是镜像恢复模拟,不会终止真实进程或测试真实磁盘掉电。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
@@ -5,12 +9,18 @@ import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
// 固定项目作用域,便于服务端版本向量与恢复镜像始终指向同一根 canvas。
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
// 原始文本目标只存在第一代;所有恢复场景从同一内容基线出发。
const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1},original=createDocument(root,[{id:root,parent:null}],[{...target,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:'Original',text:''}}]);
// 用可选本地镜像、服务端文档和历史回执初始化一套独立环境,分别模拟本地与远程已经完成到哪一步。
function fixture(image,initialServer=original,initialReceipt){
let owner,saved=image&&structuredClone(image),server=initialServer,version=initialReceipt?2:1,now=1000,next=0;
// 每次 commit 捕获一个持久化切点;calls 用于区分恢复后的查询与实际写入。
const cuts=[],calls=[],timers=new Map(),receipts=new Map(initialReceipt?[[initialReceipt.request_id,structuredClone(initialReceipt)]]:[]);
// 从当前服务端状态生成局部规范模型及其版本,作为成功回执的一部分。
const model=()=>({documentFormatVersion:1,projectId:root,complete:false,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[root]:version},removedCanvasIds:[]});
// 查询只查原请求回执;首次写入应用条件操作并递增版本,重复请求直接返回已有回执。
const transport=async input=>{
calls.push(input);
if(input.method==='GET'){const receipt=receipts.get(input.path.split('/').at(-1));return receipt?{status:200,body:receipt}:{status:404,body:{status:'unknown',request_id:input.path.split('/').at(-1)}};}
@@ -18,34 +28,38 @@ function fixture(image,initialServer=original,initialReceipt){
const body=JSON.parse(input.body);assert.equal(body.expected_versions[root],version);server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));version++;
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model()};receipts.set(input.requestId,receipt);return{status:200,body:receipt};
};
// 内存 owner 保证当前引擎独占;每次保存克隆镜像,防止后续状态污染选定的崩溃切点。
const storage={async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;this.active=false;}};},async recover(){return saved&&structuredClone(saved);},async commit(leader,batch){assert.strictEqual(leader,owner);saved=structuredClone(batch.image);cuts.push(saved);return{durableRootRevision:saved.rootRevision};},async readFile(){},close(){}};
// 接入真实 Foundry 文档适配器;假时钟只登记回调,由 drain 手动推进重试。
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({registry,workspaceId:workspace,rootId:root,transport}),clock:{now:()=>now,setTimer(callback,delay){const id=++next;timers.set(id,{callback,due:now+delay});return id;},clearTimer:id=>timers.delete(id)}};
// drain 有界推进收敛和到期重试;若八轮后仍有未解决操作即失败,不以无限等待掩盖恢复停滞。
return{cuts,calls,receipts,server:()=>server,open:()=>openProjectEngine(options),async drain(engine){for(let step=0;step<8;step++){await engine.settle();if([...engine.getSnapshot().runtime.ledger.operations.values()].every(operation=>operation.phase==='resolved'))return;now+=100000;for(const[id,timer]of[...timers])if(timer.due<=now){timers.delete(id);timer.callback();}}assert.fail('recovered operations did not settle');}};
}
// 先收集 pending、frozen、dispatch-marked、resolved 四种落盘状态,再用六个子场景分别核对恢复策略和已发生的外部副作用。
test('each durable crash cut restores its own document/history/ledger and external side-effect state',async t=>{
const producer=fixture(),engine=await producer.open();await engine.persist();const initial=structuredClone(producer.cuts.at(-1));
const edited=engine.editor.dispatch({type:'element.data',target,patch:{title:'Changed'}});assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'Changed');assert.ok(engine.getDurability().rootRevision>engine.getDurability().durableRootRevision);
await producer.drain(engine);const decoded=producer.cuts.map(image=>({image,state:decodeRecovery(image,registry)})),request=[...engine.getSnapshot().runtime.ledger.requests.values()][0],receipt=producer.receipts.get(request.id),changed=producer.server();
const pending=decoded.find(({state})=>state.runtime.ledger.operations.size===1&&state.runtime.ledger.requests.size===0),frozen=decoded.find(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='frozen'),dispatch=decoded.find(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='dispatch-marked'),resolved=decoded.findLast(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='resolved');
assert.ok(pending&&frozen&&dispatch&&resolved,'producer did not expose every required durable transition');await engine.close();
// 初始镜像没有尚未落盘的可见编辑;后续每个场景都明确指定应查询/写入的次数及服务端是否已执行。
for(const scenario of[
{name:'visible edit before durability is absent after crash and was never safe to leave',image:initial,edited:false,posts:0,queries:0},
{name:'durable pending intent dispatches once with its original logical identity',image:pending.image,edited:true,posts:1,queries:0},
// Recovery conservatively queries this cut too; unknown permits the exact
// retry, not a new identity or a claim that no effect could have happened.
// frozen 切点也保守查询;unknown 仅允许原请求重试,不能改用新身份或宣称服务未发生副作用。
{name:'durable frozen request is reconciled then sends its original bytes only once',image:frozen.image,edited:true,posts:1,queries:1,exact:true},
{name:'dispatch-marked before server commit queries unknown then retries the exact request',image:dispatch.image,edited:true,posts:1,queries:1,exact:true},
{name:'server commit before local receipt queries without repeating the write',image:dispatch.image,edited:true,posts:0,queries:1,server:changed,receipt,exact:true},
{name:'durable receipt and baseline do not replay the acknowledged edit or history',image:resolved.image,edited:true,posts:0,queries:0,server:changed,receipt},
])await t.test(scenario.name,async()=>{
// The server state is matched to the cut: it is never rewound after a
// dispatched effect merely because an older local image was selected.
// 本地 dispatch 切点可对应服务执行前或执行后;若服务已经提交,不能因读取旧镜像就把服务状态回退。
const f=fixture(scenario.image,scenario.server,scenario.receipt),recovered=await f.open();
try{
await f.drain(recovered);const state=recovered.getSnapshot();assert.equal(state.document.elements.get('a').data.title,scenario.edited?'Changed':'Original');assert.equal(f.server().elements.get('a').data.title,scenario.edited?'Changed':'Original');assert.equal(state.history.undo.length,scenario.edited?1:0);assert.equal(state.operations.size,scenario.edited?1:0);
assert.equal(f.calls.filter(call=>call.method==='POST').length,scenario.posts);assert.equal(f.calls.filter(call=>call.method==='GET').length,scenario.queries);
if(scenario.edited)assert.ok(state.runtime.ledger.operations.has(edited.operationIds[0]));
// 身份核对覆盖 GET 路径和 POST requestId;实际重发还必须保留同一冻结 body。
if(scenario.exact)for(const call of f.calls){assert.equal(call.requestId??call.path.split('/').at(-1),request.id);if(call.method==='POST')assert.equal(call.body,request.body);}
assert.equal(recovered.getDurability().rootRevision,recovered.getDurability().durableRootRevision);assert.equal(recovered.sessions.getSnapshot().views.size,0);
}finally{await recovered.close();}
+10
View File
@@ -1,22 +1,32 @@
/**
* 批量创建草稿的不可变前缀与工作量测试。以1000条底表检查逐条stage不重复复制整表,旧前缀不随追加变化。
* 策略、schema和runtime拒绝仍必须整次事务不发布;计数/替换迭代器只测本路径,不声称所有创建成本固定。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createEditor,createRegistry,builtinComponents,isFactoryDocument} from '../../src/lib/canvas-core/index.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());
// 连续追加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',()=>{
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);}
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',()=>{
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',()=>{
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',()=>{
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',()=>{
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 assert from 'node:assert/strict';
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';
const registry = createRegistry(builtinComponents());
/** 固定image第一生命周期,删除恢复后这个异步任务目标应失效。 */
const target = { id: 'image', incarnation: 1 };
/** 先创建260×200图片并记录创建history entry,propose统一尝试改默认值为260×130,允许覆盖editor依赖。 */
function setup(options = {}) {
let id = 0;
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];
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) {
let document = plan.document;
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'));
return document;
}
// 创建后移位/旋转/改名,默认尺寸修正保留这些编辑与栈顺序;原editor根和序列化intent均不变。
test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => {
const { editor, entry, propose } = setup();
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);
traverse(plan);
});
// 位置变化撤销后仍在redo,规划保留redo且当前x不提前跳到未来值;实际重做再得到90与新默认高度。
test('an unrelated redo stack is retained and rebased without becoming the working document', () => {
const { editor, propose } = setup();
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.height, 130);
});
// resize已撤销时允许修默认基线;redo仍用用户300高度,逆向回到修正后的130。
test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => {
const { editor, propose } = setup();
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(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', () => {
const { editor, propose } = setup();
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();
assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before);
});
// 创建已撤销,原图片不存在,异步默认尺寸任务以target_changed拒绝。
test('an undone creation cannot be enriched', () => {
const { editor, propose } = setup(); editor.history.undo();
assert.throws(propose, { code: 'creation_size_target_changed' });
});
// 删除再恢复使生命周期变化,即使ID相同,旧创建尺寸任务也不能修改新对象。
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();
// Undo of removal creates a new incarnation: an old asynchronous task is stale.
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', () => {
const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } });
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.equal(plan.history.entries.set, undefined);
});
// 允许raw changes构造单位×2与几何同比变化,修正尺寸按单位换算而非误判成用户resize,保留最新位置并遍历历史。
test('a logical unit conversion preserves size, latest position and the full undo path', () => {
const { editor, propose } = setup({ allowRawChanges: true });
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);
traverse(plan);
});
// 图片移入contentScale=2子图后仍保留owner/位置,默认高度换到局部65;undo只移图片不删初始frame,再重做一致。
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 { 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);
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', () => {
const { editor, propose } = setup({ historyLimit: 1 });
editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } });
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', () => {
const { editor, propose } = setup();
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));
assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image'));
});
// Infinity尺寸拒绝;远端内容已改变使历史基准过期,同样拒绝且不改editor根。
test('invalid sizes and stale document baselines are rejected without mutation', () => {
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' });
@@ -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.strictEqual(editor.getSnapshot(), stale);
});
// 0.5高不满足组件尺寸约束,规划必须拒绝,不能因默认值修正绕过普通resize限制。
test('component constraints cannot be bypassed by a default size proposal', () => {
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' });
});
// 默认宽改520、高130,redo只改高300时应保留新默认宽,避免拿旧整份geometry覆盖未编辑轴。
test('a redo edit to one size axis preserves the corrected default on the other axis', () => {
const { editor, entry } = setup();
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';
// 在独立数据库内直接探查 files/asset-bytes;以相同字节、不同原始 MIME 的两个资源检查指针语义。
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;
// 构造服务端资源、上传状态、最小 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});
// 项目文件主键仍含项目作用域,两个 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}];
// 另开原始 IDB 连接,检查数据库版本及底层记录,不借助待验证的 storage.readFile 推断行类型。
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);});
// 统计物理行,证明按 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);});
// 直接以事务覆盖底层行以注入未来 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);});
// 断言指定异步错误码;预期失败却返回成功时也抛错,避免 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}`);};
// 固定 authority、宽限期和扫描上限,仅改变当前时间来分别触发标记与 purge。
const collect=now=>storage.assetDepot.collect(p,{authority,now,retentionMs:10,limit:128});
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');
@@ -15,37 +29,53 @@ export async function depotFilesCase(assert){
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.
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');}
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');
// 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');
// 最后的 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');
// 授权补回 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');
// 注入未来指针格式应报告缺少解释器;已知格式的摘要损坏应报告 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);
// 旧版 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');
}finally{raw?.close();await p?.release();await q?.release();storage.close();indexedDB.deleteDatabase(name);}
}
// 手工建 v3 数据库及校验有效的 checkpoint/Blob,交给新 storage 打开,再在同修订 reindex 时懒提升到指针。
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))));
// 模拟旧 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);});
// 在单个旧版事务里写 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;
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){
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();
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);}};
// 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 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{
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();
// 先等待旧无引用 batch 进入屏障,再进行 GC;这控制了可重现的竞态顺序,避免靠时间猜测。
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.
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');
}finally{release?.();await engine?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
}
+8
View File
@@ -1,13 +1,21 @@
/**
* 摘要池的容量与生命周期测试。活动执行数和 FIFO 等待数分别受限;调用者取消不等于底层执行器已经退出。
* 以固定格式摘要和手动完成的执行器隔离哈希算法,专门验证调度、关闭及输出校验。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDigestPool} from '../../src/lib/canvas-runtime/digest-pool.ts';
// hash 仅作合法输出样本;tick 让执行器的微任务/定时器推进,便于检查当前实际启动次序。
const hash='a'.repeat(64),tick=()=>new Promise(resolve=>setTimeout(resolve,0));
// 两个执行槽加两个排队名额:第五个拒绝容量,取消排队的 C 后仅 A/B 启动,A 完成才按 FIFO 启动 D;关闭后拒绝新任务。
test('digest pool bounds execution and FIFO queue, rejects overload and drops queued cancellation',async()=>{
const started=[],release=[];const pool=createDigestPool({concurrency:2,maxQueued:2,execute:async bytes=>{started.push(await bytes.text());return new Promise(resolve=>release.push(()=>resolve(hash)));}}),cancel=new AbortController(),a=pool.digest(new Blob(['A'])),b=pool.digest(new Blob(['B'])),c=pool.digest(new Blob(['C']),cancel.signal),d=pool.digest(new Blob(['D']));const rejected=assert.rejects(c,/import_aborted/);await assert.rejects(pool.digest(new Blob(['overflow'])),/digest_capacity/);cancel.abort();await rejected;await tick();assert.deepEqual(started,['A','B']);release[0]();await a;await tick();assert.deepEqual(started,['A','B','D']);release[1]();release[2]();await Promise.all([b,d]);pool.close();await assert.rejects(pool.digest(new Blob()),/digest_pool_closed/);
});
// 取消活动 first 后先拒绝调用者,但底层仍挂起时 second 不能启动;first 真正退出后才复用槽位,并忽略其迟到输出。
test('active cancellation cannot free a compute slot before its executor stops; late output is ignored',async()=>{
let release;const started=[],controller=new AbortController(),pool=createDigestPool({concurrency:1,execute:async bytes=>{started.push(await bytes.text());if(started.length===1)await new Promise(resolve=>release=resolve);return hash;}}),first=pool.digest(new Blob(['first']),controller.signal),rejected=assert.rejects(first,/import_aborted/),second=pool.digest(new Blob(['second']));await tick();controller.abort();await rejected;await tick();assert.deepEqual(started,['first']);release();assert.equal(await second,hash);pool.close();
});
// close 幂等地取消活动/排队调用并向实际执行器传递 abort;新池中格式错误的 execute 输出必须拒绝。
test('closing digest pool aborts active/queued tasks once and malformed executor output is rejected',async()=>{
const signals=[],pool=createDigestPool({concurrency:1,execute:(_bytes,signal)=>{signals.push(signal);return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('stopped')),{once:true}));}}),a=pool.digest(new Blob()),b=pool.digest(new Blob()),failures=[assert.rejects(a,/import_aborted/),assert.rejects(b,/import_aborted/)];await tick();pool.close();pool.close();await Promise.all(failures);assert.equal(signals.length,1);assert.equal(signals[0].aborted,true);const invalid=createDigestPool({execute:async()=> 'bad'});await assert.rejects(invalid.digest(new Blob()),/invalid_digest_response/);invalid.close();
});
@@ -1,6 +1,11 @@
/**
* Worker 内部摘要算法选择测试。WebCrypto 可用且未超过单次内存预算时走原生路径,否则使用流式 SHA-256。
* 原生读取/计算失败必须保留原错误,不能默默换算法重试而掩盖资源或执行环境问题。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';import {createHash} from 'node:crypto';
import {digestWorkerSource,nativeDigestByteLimit} from '../../src/lib/canvas-runtime/digest-worker-source.ts';
// 空输入、Unicode 和二进制分别比较原生/流式结果,确认预算内原生路径只调用一次正确的 SHA-256 算法。
test('native Worker hashing and streaming fallback produce identical SHA256 for empty, UTF8 and binary sources',async()=>{
for(const bytes of [new Blob(),new Blob(['中文 😀']),new Blob([new Uint8Array([0,255,128,10])])]){
let calls=0;const native={digest(algorithm,input){calls++;assert.equal(algorithm,'SHA-256');assert.equal(input.byteLength,bytes.size);return crypto.subtle.digest(algorithm,input);}};
@@ -8,14 +13,19 @@ test('native Worker hashing and streaming fallback produce identical SHA256 for
}
});
// 整块读取失败、原生 digest 抛错或返回错误长度均明确拒绝;不能改走流式路径生成看似成功的结果。
test('native read or digest failures stay visible and never silently retry through the streaming fallback',async()=>{
// Blob 替身让整块读取失败,同时把 slice 设为陷阱;若代码误走流式兜底会产生另一错误。
class BrokenRead extends Blob{arrayBuffer(){return Promise.reject(Error('read failed'));}slice(){throw Error('unexpected fallback');}}
await assert.rejects(digestWorkerSource(new BrokenRead(['input'])),/read failed/);
const bytes=new Blob(['input']);await assert.rejects(digestWorkerSource(bytes,{digest(){throw Error('native failed');}}),/native failed/);await assert.rejects(digestWorkerSource(bytes,{async digest(){return new ArrayBuffer(1);}}),/invalid_digest_response/);
});
// 超过原生分配预算的输入禁止整块读取及 WebCrypto;仅按最多 1 MiB 块计算,结果与 Node 独立摘要一致。
test('inputs above the native allocation budget use bounded chunks and never request a whole buffer',async()=>{
// 构造刚超过原生预算的逻辑输入,不实际申请整块大数组;Node createHash 独立计算相同零字节序列。
const size=nativeDigestByteLimit+1,hash=createHash('sha256');let largest=0;
// 伪 Blob 报告大尺寸,禁止整块 arrayBuffer,只允许 slice 返回有限大小的零字节块并记录峰值。
class ChunkOnly extends Blob{get size(){return size;}arrayBuffer(){throw Error('unbounded read');}slice(start=0,end=size){const count=Math.max(0,Math.min(size,end)-start);largest=Math.max(largest,count);return new Blob([new Uint8Array(count)]);}}
for(let cursor=0;cursor<size;cursor+=1024*1024)hash.update(new Uint8Array(Math.min(1024*1024,size-cursor)));
const digest=await digestWorkerSource(new ChunkOnly(),{digest(){throw Error('native budget exceeded');}});assert.equal(digest,hash.digest('hex'));assert.ok(largest<=1024*1024);
+15
View File
@@ -1,24 +1,35 @@
/**
* 资源显示租约缓存测试。按 owner 和资源键共享进行中的读取/URL,最后租约释放即清理,不保留空闲 URL。
* 每个等待者可独立取消;旧读取迟到结果应清理自身,不能替换同键的新代次或破坏其他 owner。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDisplayCache } from '../../src/lib/canvas-runtime/display-cache.ts';
// 手动 gate 可返回成功显示源或抛错,让取消和迟到清理时序可控。
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
// 推进事件循环等待 load/清理执行,不做真实磁盘或浏览器图片加载。
const tick = () => new Promise(resolve => setImmediate(resolve));
// 两个租约共享一次 load 和同一 URL,release 幂等;最后释放清理后再 acquire 必须重新 load,关闭清理新源。
test('concurrent display leases share one source until the last idempotent release; there is no idle URL retention', async () => {
const cache = createDisplayCache(), owner = {}; let loads = 0, disposed = 0;
// 可观测显示源工厂:每次真实读取产生新 URL,源 release 计数代表底层资源释放。
const load = async () => ({ url: `blob:${++loads}`, release: () => disposed++ });
const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load);
assert.deepEqual(await Promise.all([a.ready, b.ready]), ['blob:1', 'blob:1']); assert.equal(loads, 1);
a.release(); a.release(); assert.equal(disposed, 0); b.release(); assert.equal(disposed, 1);
const c = cache.acquire(owner, 'asset', load); assert.equal(await c.ready, 'blob:2'); c.release(); cache.close(); assert.equal(disposed, 2);
});
// 取消一个等待租约只拒绝它自己的 ready,共享 AbortSignal 仍有效;最后一个 reader 释放才 abort 并清理。
test('cancelling one waiting lease does not abort a shared load or another reader', async () => {
const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, disposed = 0;
// 捕获共享读取的 AbortSignal 并挂起,区分释放单个租约与终止整个底层读取。
const load = async value => { signal = value; return gate.promise; };
const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load); await tick();
const cancelled = assert.rejects(a.ready, { code: 'display_released' }); a.release(); await cancelled; assert.equal(signal.aborted, false);
gate.resolve({ url: 'blob:shared', release: () => disposed++ }); assert.equal(await b.ready, 'blob:shared'); b.release(); assert.equal(signal.aborted, true); assert.equal(disposed, 1);
});
// 最后旧租约释放即 abort,随后同键可新建;旧源迟到只释放自身,不能替换/逐出新缓存条目。
test('last release aborts a load; a late source cannot replace or evict a newly acquired entry', async () => {
const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, oldDisposed = 0, newDisposed = 0;
const old = cache.acquire(owner, 'asset', async value => { signal = value; return gate.promise; }); await tick();
@@ -27,12 +38,14 @@ test('last release aborts a load; a late source cannot replace or evict a newly
gate.resolve({ url: 'blob:old', release: () => oldDisposed++ }); await tick(); assert.equal(oldDisposed, 1); assert.equal(newDisposed, 0);
const peer = cache.acquire(owner, 'asset', async () => { throw Error('unexpected duplicate read'); }); assert.equal(await peer.ready, 'blob:new'); peer.release(); next.release(); assert.equal(newDisposed, 1);
});
// 读取失败保留原错误码并移除失败条目,新 acquire 可重新读取成功,不永久缓存错误。
test('read failure retains its cause and removes the failed cache entry for a fresh retry', async () => {
const cache = createDisplayCache(), owner = {};
const a = cache.acquire(owner, 'asset', async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); });
await assert.rejects(a.ready, { code: 'disk_failed' });
const b = cache.acquire(owner, 'asset', async () => ({ url: '/remote' })); assert.equal(await b.ready, '/remote'); b.release(); cache.close();
});
// 同资源 ID 不同 owner 各有源;releaseOwner 只关闭自己的作用域并禁止再次领取,不影响另一个 owner 的共享租约。
test('project owner scopes isolate identical IDs and retired owners cannot reacquire', async () => {
const cache = createDisplayCache(), a = {}, b = {}; let disposed = 0;
const first = cache.acquire(a, 'same-id', async () => ({ url: 'blob:a', release: () => disposed++ }));
@@ -41,6 +54,7 @@ test('project owner scopes isolate identical IDs and retired owners cannot reacq
assert.throws(() => cache.acquire(a, 'same-id', async () => ({ url: '/forbidden' })), { code: 'display_scope_closed' });
const peer = cache.acquire(b, 'same-id', async () => { throw Error('unexpected'); }); assert.equal(await peer.ready, 'blob:b'); peer.release(); second.release(); assert.equal(disposed, 2);
});
// close 立即拒绝挂起 ready,迟到源仍需释放;关闭后新 acquire 拒绝,多次 close 安全。
test('cache close rejects waiting readers immediately and disposes late data without reopening a scope', async () => {
const cache = createDisplayCache(), gate = deferred(); let disposed = 0;
const lease = cache.acquire({}, 'asset', () => gate.promise); await tick();
@@ -48,6 +62,7 @@ test('cache close rejects waiting readers immediately and disposes late data wit
gate.resolve({ url: 'blob:late', release: () => disposed++ }); await tick(); assert.equal(disposed, 1);
assert.throws(() => cache.acquire({}, 'asset', async () => ({ url: '/late' })), { code: 'display_scope_closed' }); cache.close();
});
// 空 URL 等非法源需清理已分配资源;某个 release 抛错通过 onError 报告,不能阻止其他 owner 的清理。
test('invalid sources clean their allocation and cleanup errors do not interrupt other owners', async () => {
const errors = [], cache = createDisplayCache({ onError: error => errors.push(error) }); let disposed = 0;
const invalid = cache.acquire({}, 'invalid', async () => ({ url: '', release: () => disposed++ })); await assert.rejects(invalid.ready, { code: 'invalid_display_source' }); assert.equal(disposed, 1);
+36
View File
@@ -1,3 +1,8 @@
/**
* 分区解散和重新挂接测试。解散只移除 owning canvas,将其直接成员提升到父 canvas,保留入口元素及其连线。
* 几何换算必须保留世界姿态,子分区只改父绑定;声明的 canvas 引用跟随重映射,普通字符串不自动替换。
* 最后一例用真实 Foundry 协议适配器和内存服务检查单请求、丢 ACK 恢复及跨重开的 undo/redo。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createEditor,createRegistry,builtinComponents,defineComponent,movable,editable,validateDocument,applyChanges,canvasTransform,toWorld,unitScale} from '../../src/lib/canvas-core/index.ts';
@@ -5,26 +10,42 @@ import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
import {loadingCommand} from '../../src/lib/canvas-runtime/loading.ts';
// 统一节点身份、owner 和几何;frame 复用该外壳,单独补充内容缩放及单位缩放。
const node=(id,owner='P',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:120,height:80,rotation:5},data:{title:id,text:'kept'}});
// 已知 bezier-chain 路径包含锚点和手柄,解散时可以换算;未知路径类型则应原子拒绝。
const curve={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:60},in:{x:-5,y:0},out:{x:5,y:0}}]};
// 连线用端点引用和 owner 表达图关系,data.label 供检查结构修改不会清空内容。
const edge=(id,source,target,owner='P',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,source:{elementId:source},target:{elementId:target},path,data:{label:'keep'}});
// 自定义 references 类型只声明 views[*].canvas 为引用,literal 即使同值也不是引用;保留未知扩展字段。
const registry=createRegistry([...builtinComponents(),defineComponent({id:'references',dataVersion:1,structure:'node',validate:()=>true,features:[movable(),editable()],copy:{references:[{path:['views','*','canvas'],target:'canvas',external:'preserve'}],omit:[]}})]);
// 三层 P→S→T,包含跨层连线、入口连线和显式引用;父 frame 带旋转与缩放以暴露坐标换算错误。
const source=()=>createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G',future:'retain'}}],[
{...node('F','P','frame'),geometry:{x:100,y:70,width:500,height:400,rotation:33},data:{title:'F',text:'',presentation:{contentScale:2,unitScale:1.2},ports:[{id:'in',nodeId:'A'}]}},
{...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75,unitScale:.8}}},node('A','S'),node('B','T'),node('C'),
{...node('R','P','references'),data:{views:[{canvas:'S',future:true}],literal:'S'}},edge('incoming','C','F'),edge('cross','C','B'),edge('inside','A','G','S',curve),
]);
// 目标 S 与其入口 F 的第一代身份必须共同匹配,不能仅凭 canvasId 解散任意分区。
const command={type:'canvas.dissolve',canvasId:'S',entry:{id:'F',incarnation:1}};
// 浮点姿态用 1e-7 容差比较,避免旋转缩放的舍入误差干扰结构语义检查。
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
// 把元素中心、大小、角度和单位比例换算到世界坐标,比较解散前后用户实际看到的姿态。
function pose(document,id){const element=document.elements.get(id),t=canvasTransform(document,element.ownerCanvasId),g=element.geometry;return{...toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width:g.width*t.scale,height:g.height*t.scale,rotation:(g.rotation+t.rotation)%360,units:unitScale(element.data.presentation)*t.scale};}
// 集中验证提升结果:S 消失但入口、元素数和身份保留,直接成员迁移,孙层成员与父扩展信息保留。
function checkPromoted(before,after){
validateDocument(after,registry);assert.equal(after.canvases.has('S'),false);assert.deepEqual(after.elements.get('F'),before.elements.get('F'));assert.deepEqual(after.elements.get('incoming'),before.elements.get('incoming'));
assert.equal(after.elements.size,before.elements.size);assert.equal(after.elements.get('A').ownerCanvasId,'P');assert.equal(after.elements.get('G').ownerCanvasId,'P');assert.equal(after.elements.get('B').ownerCanvasId,'T');assert.deepEqual(after.elements.get('B'),before.elements.get('B'));assert.deepEqual(after.canvases.get('T').parent,{...before.canvases.get('T').parent,canvasId:'P'});
// 分别比较直接节点、子分区入口和孙层节点的世界姿态,防止只验证 owner 而漏掉嵌套坐标变化。
for(const id of['A','G','B']){const a=pose(before,id),b=pose(after,id);for(const key of Object.keys(a))near(a[key],b[key]);assert.equal(after.elements.get(id).incarnation,before.elements.get(id).incarnation);}
// 内部连线提升到父 canvas 后,路径锚点需换算到同一世界位置;原跨层连线的 owner 本来就是 P。
assert.equal(after.elements.get('inside').ownerCanvasId,'P');const a=toWorld(canvasTransform(before,'S'),curve.knots[0].position),b=toWorld(canvasTransform(after,'P'),after.elements.get('inside').path.knots[0].position);near(a.x,b.x);near(a.y,b.y);
// 仅重写注册表声明的 views.canvas;literal 字符串和 future 扩展值原样保留。
assert.equal(after.elements.get('cross').ownerCanvasId,'P');assert.deepEqual(after.elements.get('R').data,{views:[{canvas:'P',future:true}],literal:'S'});
}
// can 只查询能力且不发布;dispatch 一次通知、一个操作和一个 undo 单元,undo 精确还原,redo 再次得到相同世界姿态。
test('dissolve promotes direct members once, preserves world poses, nested ownership, entry lines and declared references',()=>{
const document=source(),editor=createEditor({document,registry}),before=editor.getSnapshot();let notifications=0;editor.subscribe(()=>notifications++);
assert.equal(editor.can(command).enabled,true);assert.strictEqual(editor.getSnapshot(),before);
@@ -33,6 +54,7 @@ test('dissolve promotes direct members once, preserves world poses, nested owner
assert.equal(editor.history.redo().status,'applied');checkPromoted(document,editor.getSnapshot().document);assert.equal(editor.getSnapshot().history.undo.length,1);
});
// 根 canvas、旧入口身份、错误入口、成员/引用策略拒绝、未知元素和未知路径都不能留下半次提升;Root、query 和订阅次数保持不变。
test('dissolve rejects root, stale entry, member policy, unsupported member and opaque curve without partial publication',()=>{
const document=source();for(const options of[
{command:{...command,canvasId:'P'}},
@@ -48,17 +70,20 @@ test('dissolve rejects root, stale entry, member policy, unsupported member and
}
});
// 空 canvas 解散只移除绑定,入口元素不删除、不分配替代元素、不产生元素墓碑;undo 恢复原 canvas 集合。
test('an empty owning canvas dissolves without deleting its entry or allocating replacement elements',()=>{
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame')]),editor=createEditor({document,registry});
assert.equal(editor.dispatch(command).status,'applied');assert.deepEqual([...editor.getSnapshot().document.elements],[...document.elements]);assert.equal(editor.getSnapshot().document.tombstones.size,0);assert.equal(editor.history.undo().status,'applied');assert.deepEqual([...editor.getSnapshot().document.canvases],[...document.canvases]);
});
// reparent 只改 canvas 父入口绑定,不递归改子孙元素 owner;制造父子循环时整体拒绝。
test('reparent changes the canvas binding without recursively rewriting descendant owners and cycles remain atomic failures',()=>{
const original=source(),editor=createEditor({document:original,registry});assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'S',parent:{canvasId:'P',entryElementId:'C'}}).status,'applied');
const document=editor.getSnapshot().document;assert.deepEqual(document.canvases.get('S').parent,{canvasId:'P',entryElementId:'C'});for(const id of['A','G','B'])assert.equal(document.elements.get(id).ownerCanvasId,original.elements.get(id).ownerCanvasId);assert.equal(document.canvases.get('T').parent.canvasId,'S');validateDocument(document,registry);
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'P',parent:{canvasId:'T',entryElementId:'B'}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);
});
// 渐进加载时,即使直接成员已到齐也不能忽略尚未加载的引用元素;完整捕获源可解散,旧 v3 加载描述不足以放行。
test('dissolve waits for the complete captured source instead of promoting a partially loaded membership',()=>{
const full=source(),prefix=createDocument('P',[...full.canvases.values()],[...full.elements.values()].filter(element=>element.id!=='R')),versions=new Map([...full.canvases.keys()].map(id=>[id,1])),state={version:5,sourceToken:'dissolve-source',declaredCanvasIds:[...full.canvases.keys()],sourceCanvases:[...full.canvases.values()],elementOwners:Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId])),canvasElementCounts:Object.fromEntries([...full.canvases.keys()].map(id=>[id,[...full.elements.values()].filter(element=>element.ownerCanvasId===id).length])),receivedElements:prefix.elements.size,totalElements:full.elements.size};
assert.equal(loadingCommand(command,prefix,registry,state,prefix,{document:prefix,versions}).enabled,false);
@@ -66,11 +91,18 @@ test('dissolve waits for the complete captured source instead of promoting a par
assert.equal(loadingCommand(command,full,registry,{...state,version:3},full,{document:full,versions}).enabled,false);
});
// 符号 ID 转 UUID 后走真实编译适配器;一次请求覆盖 P/S/T,丢 ACK 只查询原请求,重开保留 undo/redo,最终共三次文档写入。
test('dissolve uses one atomic protocol request, recovers a lost ACK by its original identity, then undoes and redoes after reopen',async()=>{
const symbolic=source(),ids=Object.fromEntries([...new Set([...symbolic.canvases.keys(),...symbolic.elements.keys()])].map((id,index)=>[id,`10000000-0000-4000-8000-${String(index+1).padStart(12,'0')}`])),inverse=Object.fromEntries(Object.entries(ids).map(([name,id])=>[id,name]));
// 为协议测试双向重命名所有结构 ID:canvas、入口、owner、端点、端口、声明引用及墓碑;普通内容字符串不替换。
const rename=(document,ids)=>{const map=id=>ids[id]??id;return createDocument(map(document.projectId),[...document.canvases.values()].map(canvas=>({...canvas,id:map(canvas.id),parent:canvas.parent?{...canvas.parent,canvasId:map(canvas.parent.canvasId),entryElementId:map(canvas.parent.entryElementId)}:null})),[...document.elements.values()].map(element=>({...element,id:map(element.id),ownerCanvasId:map(element.ownerCanvasId),...(element.structure==='connection'?{source:{...element.source,elementId:map(element.source.elementId)},target:{...element.target,elementId:map(element.target.elementId)}}:{}),data:{...element.data,...(element.data.ports?{ports:element.data.ports.map(port=>({...port,nodeId:map(port.nodeId)}))}:{}),...(element.data.views?{views:element.data.views.map(view=>({...view,canvas:map(view.canvas)}))}:{})}})),new Map([...document.tombstones].map(([id,incarnation])=>[map(id),incarnation])));};
const original=rename(symbolic,ids),workspace='20000000-0000-4000-8000-000000000001';let server=original,image,owner,now=1000,lose=true;const versions=new Map([...original.canvases.keys()].map(id=>[id,1])),receipts=new Map(),calls=[];
// 为参与 canvas 生成局部回执,移除的 S 仍列入 removedCanvasIds 和版本向量。
const model=(participants,complete=false)=>({documentFormatVersion:1,projectId:original.projectId,complete,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)])),removedCanvasIds:participants.filter(id=>!server.canvases.has(id))});
// Foundry 编译器负责实际请求格式;内存 transport 检查每个参与版本,并在执行后故意丢一次响应。
const persistence=createFoundryPersistence({registry,workspaceId:workspace,rootId:ids.P,transport:async input=>{
calls.push(input);if(input.method==='GET')return{status:200,body:receipts.get(input.path.split('/').at(-1))};
const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],versions.get(id)??0);
@@ -78,7 +110,11 @@ test('dissolve uses one atomic protocol request, recovers a lost ACK by its orig
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(participants)};receipts.set(input.requestId,receipt);
if(lose){lose=false;throw Error('lost dissolve ACK');}return{status:200,body:receipt};
}});
// 保存完整恢复镜像和独占 owner;引擎重开时仍能取到原冻结请求与历史。
const storage={async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;this.active=false;}};},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(){}};
// 假时钟不自动执行定时器,重开前主动把 now 前移以触发未知请求的恢复查询。
const options={scope:{accountId:'account',workspaceId:workspace,projectId:ids.P},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage,persistence,clock:{now:()=>now,setTimer:()=>0,clearTimer(){}}};let engine=await openProjectEngine(options);
try{
assert.equal(engine.editor.dispatch({...command,canvasId:ids.S,entry:{id:ids.F,incarnation:1}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'unresolved');assert.deepEqual([...request.canvasIds].map(id=>inverse[id]).sort(),['P','S','T']);assert.equal(calls.filter(call=>call.method==='POST').length,1);checkPromoted(symbolic,rename(server,inverse));
@@ -1,13 +1,22 @@
/**
* 可恢复深复制规划器的语义检查点测试。检查点保存遍历游标、阶段及稳定映射,不保存完整节点正文。
* 任一阶段序列化/恢复后结果需与同步 planDuplicate 一致,已分配身份不能再分配,旧检查点也不能随后续 step 改变。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
import {createDuplicatePlanner,validateDuplicateCheckpoint} from '../../src/lib/canvas-core/duplicate-checkpoint.ts';
import {planDuplicateResumable} from '../../src/lib/canvas-runtime/duplicate.ts';
// 节点及曲线工厂保留独立身份、owner 和端口,文本标记用于检查检查点没有复制完整正文。
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:2,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal-data-not-in-checkpoint'},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]}});
// 三层分区含内部/跨分区边与上级入边,触发 DFS、分配、映射、节点/边输出等全部阶段。
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),{...node('F','P','frame'),geometry:{...node('F').geometry,rotation:30},data:{title:'F',text:'',presentation:{contentScale:2},ports:[{id:'in',nodeId:'A',direction:'input',handle:'left'}]}},{...node('A','S','image'),data:{title:'A',text:'',assetId:'shared',runId:'not-copied'}},{...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75}}},node('B','T'),node('D','T'),edge('incoming','C','F'),edge('deep','C','B'),edge('internal','B','D','T'),edge('across','A','B','S'),edge('outgoing','F','C')]);
// 绑定来源/schema token;targets 取真实身份,drain 有界步进到 result,未结束则报错而不返回部分计划。
const binding={sourceToken:'source-sha',schemaToken:'schema-v1'},targets=(ids=['F','A'])=>ids.map(id=>({id,incarnation:document.elements.get(id).incarnation})),drain=planner=>{for(let i=0;i<100000;i++)if(planner.step())return planner.result();throw Error('planner did not terminate');};
// 逐阶段捕获并 JSON 往返检查点,以新文档对象恢复后对照完整同步计划;分配计数一致,原检查点不变且不含标记正文。
test('each durable planning phase resumes with the same deep-copy plan and no repeated identity allocation',()=>{
// 遍历每个可持久化阶段;emit 与 emit-edges 还要求已输出至少一项,避免只覆盖空阶段入口。
const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done'];
for(const phase of phases){let next=0,planner=createDuplicatePlanner(document,registry,targets(),{createId:()=>`new-${++next}`},binding),checkpoint;
for(let i=0;i<10000;i++){const done=planner.step(),state=planner.checkpoint();if(state.phase===phase&&(phase!=='emit'||state.emitted>0)&&(phase!=='emit-edges'||state.emittedEdges>0)){checkpoint=state;break;}if(done)break;}
@@ -15,6 +24,7 @@ test('each durable planning phase resumes with the same deep-copy plan and no re
}
});
// 在嵌套 DFS 展开中停下,恢复后保留待访问兄弟项及显式选中边;给定完整映射时任何新 ID 分配都会使测试失败。
test('nested DFS expansion checkpoints preserve pending siblings, selected edges and explicit stable mappings',()=>{
for(const selection of [['F','C','incoming'],['A','internal'],['incoming']]){let reference=0;const selected=targets(selection),expected=planDuplicate(document,registry,selected,{createId:()=>`copy-${++reference}`}),mapping={elements:expected.elementIds,canvases:expected.canvasIds};let planner=createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('allocated despite complete mapping');}},binding),state;
for(let i=0;i<10000;i++){const done=planner.step();state=planner.checkpoint();if(state.expanding?.index>=0||done)break;}
@@ -22,15 +32,18 @@ test('nested DFS expansion checkpoints preserve pending siblings, selected edges
}
});
// 来源/schema 绑定不同、未来版本/阶段、负游标、非法映射或缺失工作项都拒绝;未完成时 result 也不可用。
test('checkpoint bindings, shape and source identities reject incompatible state before producing a plan',()=>{
const planner=createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},binding),state=planner.checkpoint();for(const patch of [{sourceToken:'different'},{schemaToken:'different'}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,...patch,checkpoint:state}),/binding_mismatch/);
for(const patch of [{version:2},{phase:'future'},{cursor:-2},{elementIds:[['F','C']]},{work:[{kind:'element',id:'missing'}]}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,checkpoint:{...state,...patch}}));assert.throws(()=>planner.result(),/copy_plan_not_ready/);
});
// 每片一步并持续保存,在已有至少两个元素 ID 后取消;用保存状态恢复,最终计划及 ID 分配总量仍与同步路径一致。
test('cooperative resume checkpoints advance semantic cursors, yield and stop cleanly after cancellation',async()=>{
const controller=new AbortController();let saved,ids=0,yields=0;await assert.rejects(planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},binding,{maxStepsPerSlice:1,yieldTask:async()=>{yields++;},checkpoint:async state=>{if(state.elementIds.length>=2){saved=state;controller.abort();}},signal:controller.signal}),/copy_aborted/);assert.ok(yields>0);assert.ok(saved.elementIds.length>=2);const old=ids,plan=await planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},{...binding,checkpoint:saved},{maxStepsPerSlice:2});let reference=0;assert.deepEqual(plan,planDuplicate(document,registry,targets(),{createId:()=>`async-${++reference}`}));assert.equal(ids,reference);assert.ok(ids>=old);
});
// 首次大检查点按预算协作式编码,并发推进 planner 不影响旧检查点/image;字符串编码分片且 UTF-8 计数准确,finally 恢复全局探针。
test('a first planning checkpoint encodes cooperatively and is isolated from later planner steps',async()=>{
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const {hasDetachedJsonData}=await import('../../src/lib/canvas-core/json-data.ts');
@@ -40,6 +53,7 @@ test('a first planning checkpoint encodes cooperatively and is isolated from lat
assert.equal(hasDetachedJsonData(checkpoint),true);
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('checkpoint-'))encoded++;return Reflect.apply(stringify,this,[value,...args]);};
try{
// 封存检查点尚未完成时继续推进 planner,模拟存储异步执行和后续准备并行;已捕获检查点必须保持旧值。
const pending=sealRecoveryImageAsync(17,1,1,checkpoint,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirst??=encoded;yields++;}});
for(let i=0;i<20000&&planner.progress().steps<=10;i++)planner.step();
assert.ok(planner.progress().steps>checkpoint.steps);
@@ -1,13 +1,23 @@
/**
* 复制规划的协作调度与检查点保存节奏测试。逐步扫描、分配及映射可以让出执行权,但最终计划仍需完全一致。
* 检查点保存比任务让出更稀疏,不能让慢 checkpoint I/O 本身强迫下一片立即再保存;取消或错误不发布半份计划。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,planDuplicate,planDuplicateSteps,applyChanges,validateDocument,validateDocumentSteps,validJson} from '../../src/lib/canvas-core/index.ts';
import {planDuplicateAsync,prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts';
// 使用内置复制定义,调度差异不会改变类型解释。
const registry=createRegistry(builtinComponents());
// 生成 frame/文本节点;完整数据较简单,测试聚焦大量扫描及映射步骤。
const node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'copy'},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
// 上级 C 指向 frame 的入边,验证协作式规划仍遵守上级入边复制规则。
const edge={id:'edge',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}};
// 2000 个子节点加入口、上级节点和入边构成 2003 项源,用于推动跨多个调度切片。
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('C'),...Array.from({length:2000},(_,i)=>node(`n${i}`,'S')),edge]);
// 每轮重新生成确定性的 ID 分配器,使异步与同步计划能深比较,不受全局随机 ID 干扰。
const selection=[{id:'F',incarnation:1}],options=()=>{let id=0;return{createId:()=>`new-${++id}`};};
// 固定时钟仍按每片 64 步让出两百余次;完整结果与同步路径相同,含上级入边、冻结副本及不变源文档。
test('cooperative deep planning yields throughout scanning, allocation and remapping and matches the synchronous plan',async()=>{
let slices=0;const plan=await planDuplicateAsync(document,registry,selection,options(),{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{slices++;}});
assert.ok(slices>200);assert.deepEqual(plan,planDuplicate(document,registry,selection,options()));assert.equal(validJson(plan),true);
@@ -16,18 +26,21 @@ test('cooperative deep planning yields throughout scanning, allocation and remap
assert.equal(Object.isFrozen(plan.changes[0].after.data),true);assert.equal(document.elements.size,2003);
});
// 默认 task yield 允许真实零延时定时器取消大规划;取消发生在分配前,没有 ID 或内容被发布。
test('default task yielding lets actual timers cancel a large copy without publishing content',async()=>{
const controller=new AbortController();let ids=0,ran=false;
const timer=setTimeout(()=>{ran=true;controller.abort();},0);
try{await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`unused-${++ids}`},{signal:controller.signal,maxStepsPerSlice:16}),/copy_aborted/);assert.equal(ran,true);assert.equal(ids,0);assert.equal(document.elements.size,2003);}finally{clearTimeout(timer);}
});
// 已分配部分 ID 后在 yield 中取消,明确拒绝而非返回部分计划,原文档没有任何 new-* 对象。
test('cancellation during allocation never returns a partial plan or changes the source',async()=>{
const controller=new AbortController();let allocated=0,slices=0;
await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`new-${++allocated}`},{signal:controller.signal,now:()=>0,maxStepsPerSlice:32,yieldTask:async()=>{slices++;if(allocated>20)controller.abort();}}),/copy_aborted/);
assert.ok(allocated>20&&allocated<2003);assert.ok(slices>1);assert.equal(document.elements.has('new-1'),false);
});
// 规划开始后调用者修改 selection、worldOffset 和实时根,仍使用已捕获的源与选项,不能中途混入新状态。
test('planning captures caller inputs and retains the original document when the live root changes',async()=>{
const small=createDocument('project',[{id:'P',parent:null}],[node('A')]);let live=small,changed=false;
const targets=[{id:'A',incarnation:1}],settings={...options(),worldOffset:{x:40,y:50}};
@@ -35,6 +48,7 @@ test('planning captures caller inputs and retains the original document when the
assert.equal(live.elements.size,0);assert.equal(result.changes[0].after.geometry.x,40);assert.equal(result.changes[0].after.geometry.y,50);assert.equal(small.elements.size,1);
});
// 即使步数上限很高,时间预算也会让出;宿主调度错误、非法预算和预先取消均保留各自错误。
test('time budget forces a task yield even when the step limit is high and scheduler errors propagate',async()=>{
let tick=0,yields=0;
await planDuplicateAsync(createDocument('p',[{id:'P',parent:null}],[node('A')]),registry,[{id:'A',incarnation:1}],options(),{now:()=>++tick,frameBudgetMs:1,maxStepsPerSlice:99999,yieldTask:async()=>{yields++;}});assert.ok(yields>10);
@@ -43,6 +57,7 @@ test('time budget forces a task yield even when the step limit is high and sched
const controller=new AbortController();controller.abort();await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{signal:controller.signal}),/copy_aborted/);
});
// 协作式验证和规划不能过滤掉错误记录:多根文档和旧 incarnation 仍以与同步路径一致的错误拒绝。
test('cooperative validation and planning retain invalid-input errors rather than skipping failed records',async()=>{
const invalid={...document,canvases:new Map([...document.canvases,['bad',{id:'bad',parent:null}]])};
assert.throws(()=>validateDocument(invalid,registry),/invalid_roots/);assert.throws(()=>[...validateDocumentSteps(invalid,registry)],/invalid_roots/);
@@ -50,6 +65,7 @@ test('cooperative validation and planning retain invalid-input errors rather tha
assert.throws(()=>[...planDuplicateSteps(document,registry,[{id:'F',incarnation:9}],options())],/stale_incarnation/);
});
// 源 ID 为 __proto__/constructor 时仍编码为 own JSON 字段,映射对象原型不受影响且序列化往返稳定。
test('mapping output preserves special source IDs as own JSON fields without prototype mutation',async()=>{
const source=createDocument('special',[{id:'P',parent:null}],[node('__proto__'),node('constructor')]);
const plan=await planDuplicateAsync(source,registry,[{id:'__proto__',incarnation:1},{id:'constructor',incarnation:1}],options());
@@ -57,16 +73,19 @@ test('mapping output preserves special source IDs as own JSON fields without pro
});
// 对比每次保存与间隔保存:最终映射、所有 yield 和阶段覆盖一致,而全量检查点显著减少,最大步距仍受限。
test('stream checkpoint cadence reduces full snapshots while preserving every task yield, phase and final mapping',async()=>{
const binding={sourceToken:'captured',schemaToken:'schema'},run=async interval=>{let yields=0;const checkpoints=[],state=await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>0,maxStepsPerSlice:64,checkpointIntervalMs:interval,maxStepsPerCheckpoint:2048,yieldTask:async()=>{yields++;},checkpoint:async state=>{checkpoints.push(state);}});return{state,yields,checkpoints};},every=await run(0),paced=await run(50);
assert.deepEqual(paced.state,every.state);assert.equal(paced.yields,every.yields);assert.ok(paced.checkpoints.length<every.checkpoints.length/4);const phases=new Set(paced.checkpoints.map(state=>state.phase));for(const state of every.checkpoints)assert.ok(phases.has(state.phase));assert.equal(paced.checkpoints.at(-1).phase,'emit');assert.ok(paced.checkpoints.every(Object.isFrozen));for(let i=1;i<paced.checkpoints.length;i++)assert.ok(paced.checkpoints[i].steps-paced.checkpoints[i-1].steps<=2048+64);assert.equal(document.elements.size,2003);
});
// 节奏化准备取消后复用已分配 ID;检查点回调模拟耗时 1000,下一次同阶段保存仍从回调完成后重新计时。
test('paced preparation resumes allocated IDs exactly and checkpoint I/O time does not force the next snapshot',async()=>{
const binding={sourceToken:'captured',schemaToken:'schema'},controller=new AbortController(),allocation=options();let saved;
await assert.rejects(prepareDuplicateStream(document,registry,selection,allocation,binding,{signal:controller.signal,now:()=>0,maxStepsPerSlice:64,checkpointIntervalMs:50,maxStepsPerCheckpoint:512,yieldTask:async()=>{},checkpoint:async state=>{if(state.phase==='allocate-elements'&&state.elementIds.length){saved=state;controller.abort();}}}),/copy_aborted/);assert.ok(saved.elementIds.length>0);
const resumed=await prepareDuplicateStream(document,registry,selection,allocation,{...binding,checkpoint:saved},{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{}}),complete=await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{}});assert.deepEqual(resumed,complete);assert.deepEqual(resumed.elementIds.slice(0,saved.elementIds.length),saved.elementIds);
let time=0,yields=0;const saves=[];await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>time,maxStepsPerSlice:64,checkpointIntervalMs:10,yieldTask:async()=>{time++;yields++;},checkpoint:async state=>{const start=time;time+=1000;saves.push({phase:state.phase,start,end:time});}});assert.ok(saves.length<yields/3);for(let i=1;i<saves.length;i++)if(saves[i].phase===saves[i-1].phase)assert.ok(saves[i].start-saves[i-1].end>=10);
});
// 非法 checkpoint 间隔或步数上限在任何 ID 分配/保存回调前拒绝,不能先产生部分准备成果。
test('invalid stream checkpoint cadence rejects before allocation or publication',async()=>{
for(const setting of [{checkpointIntervalMs:-1},{checkpointIntervalMs:NaN},{checkpointIntervalMs:Infinity},{maxStepsPerCheckpoint:0},{maxStepsPerCheckpoint:1.5}]){let allocated=0,published=0;await assert.rejects(prepareDuplicateStream(document,registry,selection,{createId:()=>{allocated++;return'new';}},{sourceToken:'captured',schemaToken:'schema'},{...setting,checkpoint:async()=>{published++;}}),/invalid_copy_budget/);assert.equal(allocated,0);assert.equal(published,0);}
});
+10
View File
@@ -1,3 +1,7 @@
/**
* 映射已准备好的复制源按记录游标懒输出测试,不要求先生成第二份完整输出 Blob。
* 来源摘要、registry 签名和稳定 ID 共同绑定准备状态;单条未知类型可报告记录错误,不破坏先前合法输出。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
@@ -5,21 +9,27 @@ import {prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts'
import {createDuplicatePlanSource} from '../../src/lib/canvas-runtime/duplicate-source.ts';
import {copyRegistrySignature} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
// 内置定义、可覆盖节点工厂和固定信号,准备源使用一致类型 schema。
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:100,height:100,rotation:0}}),signal=new AbortController().signal;
// 旋转缩放 frame 包含端口和子节点,并有上级入边,覆盖懒映射的几何及引用一致性。
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'}],presentation:{contentScale:2}},geometry:{...node('F').geometry,rotation:30}},node('A','S'),{id:'incoming',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'A'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:40,y:60},in:{x:0,y:0},out:{x:1,y:1}}]}}]);
// 以小切片只完成映射准备,绑定来源摘要与当前 registry 签名,停在尚未 emit 的阶段。
async function prepare(source=document){let next=0;return prepareDuplicateStream(source,registry,[{id:'F',incarnation:1}],{createId:()=>`copy-${++next}`},{sourceToken:'a'.repeat(64),schemaToken:copyRegistrySignature(source,registry)},{maxStepsPerSlice:2});}
// 从末记录倒序读取仍逐项匹配同步计划;序列化后的准备状态可重建同一行,越界结束、取消及负游标分别正确处理。
test('lazy copy source seeks mapped records with exact synchronous geometry and reference parity',async()=>{
const state=await prepare(),source=createDuplicatePlanSource(document,registry,state,'snapshot');let next=0;const plan=planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=>`copy-${++next}`});assert.equal(state.phase,'emit');assert.equal(state.emitted,0);assert.equal(state.emittedEdges,0);
for(let i=plan.changes.length-1;i>=0;i--){const line=await source.read(i,signal);assert.deepEqual(line.value.change,plan.changes[i]);assert.equal(line.nextCursor,i+1);assert.deepEqual(await createDuplicatePlanSource(document,registry,JSON.parse(JSON.stringify(state)),'snapshot').read(i,signal),line);}
assert.equal(await source.read(plan.changes.length,signal),null);const aborted=new AbortController();aborted.abort();await assert.rejects(source.read(0,aborted.signal),/import_aborted/);await assert.rejects(source.read(-1,signal),/invalid_import_cursor/);
});
// 准备映射时不必转换未知尾节点;懒源先输出合法项,再为 opaque 记录报告 copy_schema_unavailable,而同步全计划会整体拒绝。
test('mapping preparation completes before converting unsupported tail records; source reports per-record failure',async()=>{
const sourceDocument=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('good','S'),node('opaque','S','unknown-widget')]),state=await prepare(sourceDocument),source=createDuplicatePlanSource(sourceDocument,registry,state,'snapshot');
const lines=[];for(let i=0;;i++){const line=await source.read(i,signal);if(!line)break;lines.push(line);}assert.ok(lines[0].value);assert.ok(lines.some(line=>line.value?.change.after?.data?.title==='good'));assert.equal(lines.at(-1).error,'copy_schema_unavailable');assert.equal(lines.at(-1).key,'element:opaque');assert.throws(()=>planDuplicate(sourceDocument,registry,[{id:'F',incarnation:1}],{createId:()=>crypto.randomUUID()}),/copy_schema_unavailable/);
});
// 运行 job 校验 snapshot SHA、parser 版本、稳定 ID、合法游标及未输出的 plan;任一绑定被篡改即拒绝。
test('persisted record cursor source binds snapshot SHA, mapping and interpreter without a second output Blob',async()=>{
const state=await prepare(),stableIds=Object.fromEntries([...state.elementIds.map(([id,mapped])=>[`element:${id}`,mapped]),...state.canvasIds.map(([id,mapped])=>[`canvas:${id}`,mapped])]),job={...createImportJob({id:'job',projectId:'project',sourceFingerprint:'snapshot',parserVersion:2,source:{kind:'duplicate-plan',fileId:'file',byteLength:100,sha256:state.sourceToken,targetCanvasId:'P',plan:state,copy:{sourceRevision:1,roots:state.roots.map(id=>stableIds[`element:${id}`])}}}),status:'running',stableIds};validateImportJob(job);
for(const patch of [{parserVersion:1},{cursor:100},{stableIds:{}},{source:{...job.source,sha256:'b'.repeat(64)}},{source:{...job.source,plan:{...state,emitted:1}}}])assert.throws(()=>validateImportJob({...job,...patch}));
+20
View File
@@ -1,15 +1,28 @@
/**
* 核心深复制产品规则测试:父子选择去重、分区树与引用映射、上级入边重接到副本,后代世界偏移只应用一次。
* 图片引用复用已有资源,执行任务元数据按 schema 省略;普通字面量和未知可保留字段不能被当 ID 随意替换。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,defineComponent,planDuplicate,applyChanges,validateDocument,canvasTransform,toWorld} from '../../src/lib/canvas-core/index.ts';
// 第三代文本节点工厂,副本应分配全新 ID/第一代身份;字面量 A 用来检查是否误当引用重写。
const node=(id,owner='P',componentId='text')=>({id,incarnation:3,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal A',presentation:{contentScale:1},future:{keep:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}});
// 带端点控制量和结点的贝塞尔路径,跨缩放坐标系复制时需分别核对位置及局部控制量。
const path={type:'bezier-chain',startControl:{x:5,y:6},endControl:{x:-7,y:8},knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]};
// 第二代连线引用 source/target 元素及局部 portId;复制时元素引用重映射,portId 不改。
const edge=(id,source,target,owner='P',curve=null)=>({id,incarnation:2,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},path:curve,data:{title:id}});
// 两层 frame 的 contentScale 与图片任务状态样本,分别验证坐标和资源/执行字段策略。
const registry=createRegistry(builtinComponents()),F={...node('F','P','frame'),geometry:{...node().geometry,rotation:30},data:{...node().data,title:'F',presentation:{contentScale:2}}},G={...node('G','S','frame'),data:{...node().data,title:'G',presentation:{contentScale:.75}}},A={...node('A','S','image'),data:{title:'A',text:'A',assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task',progress:.5,runStatus:'running',custom:'A'}};
// 三层分区同时含入边、深层入边、内部边、跨子区边及出边,明确区分哪些默认跟随被复制子树。
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),F,A,G,node('B','T'),node('D','T'),edge('incoming','C','F','P',path),edge('deep','C','B'),edge('internal','B','D','T',path),edge('across','A','B','S'),edge('outgoing','F','C')]);
// 从原文档取 incarnation,防止测试用过期身份绕过核心校验。
const target=id=>({id,incarnation:document.elements.get(id).incarnation});
// 默认同时选择 F 与已包含的 A,使用确定 ID 和世界偏移;extra 可替换映射及分配器测试恢复一致性。
const fixture=(selection=['F','A'],extra={})=>{let next=0;return planDuplicate(document,registry,selection.map(target),{createId:()=>`copy-${++next}`,worldOffset:{x:32,y:48},...extra});};
// 以 1e-7 容差比较旋转/缩放后的浮点世界坐标。
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
// F/A 重复选择只复制一棵深树;内部引用和 canvas owner 映射,上级 C 保留并接入新分区,默认不复制出边,逐记录前缀都合法。
test('deep copy deduplicates ancestor selection, maps the entire tree and preserves upper incoming sources',()=>{
const before=JSON.stringify([...document.elements]),plan=fixture(),after=applyChanges(document,plan.changes);
assert.deepEqual(plan.roots,[plan.elementIds.F]);assert.equal(Object.keys(plan.canvasIds).length,2);assert.equal(Object.keys(plan.elementIds).length,9);
@@ -22,6 +35,7 @@ test('deep copy deduplicates ancestor selection, maps the entire tree and preser
let partial=document;for(const change of plan.changes){partial=applyChanges(partial,[change]);validateDocument(partial,registry);}
});
// 旋转/缩放嵌套下各节点和内部曲线只得到一次世界偏移,后代不重复平移;上级入边控制量按对应坐标规则处理。
test('geometry uses one world offset across rotated/scaled ownership and does not offset descendants twice',()=>{
const plan=fixture(),after=applyChanges(document,plan.changes);
for(const id of ['F','A','G','B','D']){
@@ -32,11 +46,13 @@ test('geometry uses one world offset across rotated/scaled ownership and does no
const incoming=after.elements.get(plan.elementIds.incoming).path;near(incoming.knots[0].position.x,path.knots[0].position.x+16);near(incoming.knots[0].position.y,path.knots[0].position.y+24);assert.deepEqual(incoming.startControl,path.startControl);
});
// 图片副本保留 assetId/src 和普通字面量,省略 runId/taskId/progress/runStatus,避免把已执行任务当新执行工作。
test('image copies share resources and omit execution metadata while preserving ordinary literal values',()=>{
const plan=fixture(),data=applyChanges(document,plan.changes).elements.get(plan.elementIds.A).data;
assert.equal(data.assetId,'shared');assert.equal(data.src,'asset:shared');assert.equal(data.custom,'A');for(const key of ['runId','taskId','progress','runStatus'])assert.equal(Object.hasOwn(data,key),false);
});
// 完整稳定映射生成相同计划且不调用 createId;碰撞 ID、多余映射范围及过期源 incarnation 都拒绝。
test('a supplied stable mapping reproduces the same plan without allocating another identity',()=>{
const first=fixture();const second=fixture(['F','A'],{mapping:{elements:first.elementIds,canvases:first.canvasIds},createId:()=>{throw Error('new identity allocated');}});assert.deepEqual(second,first);
assert.throws(()=>fixture(['F'],{createId:()=> 'C'}),/duplicate_identity/);
@@ -44,11 +60,13 @@ test('a supplied stable mapping reproduces the same plan without allocating anot
assert.throws(()=>planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=> 'new'}),/stale_incarnation/);
});
// 同时选择上级 C 和入边时,C 也生成副本,入边指向新 C/新 F,且只创建一次连线。
test('selecting the upper source too maps one new source and does not duplicate the incoming edge',()=>{
const plan=fixture(['F','A','C','incoming']),after=applyChanges(document,plan.changes),incoming=after.elements.get(plan.elementIds.incoming);
assert.equal(incoming.source.elementId,plan.elementIds.C);assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(plan.changes.filter(change=>change.kind==='element'&&change.after?.id===incoming.id).length,1);
});
// 自定义声明的列表元素引用和 canvas 引用映射到新 ID;execution 按 omit 删除,literal 不变,未选中的 reject 引用报错。
test('declared nested/list references are remapped, resources remain shared and only declared transient fields disappear',()=>{
const definition=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['links'],target:'element',many:true,external:'reject'},{path:['view'],target:'canvas',external:'reject'}],omit:[['execution']]}}),r=createRegistry([...builtinComponents(),definition]),linked={...node('L'),componentId:'linked',data:{title:'L',text:'A',links:['A','B'],view:'S',execution:{taskId:'task'},literal:'A'}},source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),linked]);let next=0;
const plan=planDuplicate(source,r,[target('F'),{id:'L',incarnation:3}],{createId:()=>`ref-${++next}`}),copy=applyChanges(source,plan.changes).elements.get(plan.elementIds.L);
@@ -56,6 +74,7 @@ test('declared nested/list references are remapped, resources remain shared and
assert.throws(()=>planDuplicate(source,r,[{id:'L',incarnation:3}],{createId:()=>`external-${++next}`}),/copy_external_reference/);
});
// 缺复制 schema、未来数据版本、重叠/危险路径及未知曲线都明确拒绝,不能静默省略或猜测转换。
test('missing copy schema, unknown versions, ambiguous paths and opaque curves are explicit failures',()=>{
const known=defineComponent({...builtinComponents()[0],id:'opaque',copy:undefined}),r=createRegistry([...builtinComponents(),known]),source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),{...node('U'),componentId:'opaque'}]);
assert.throws(()=>planDuplicate(source,r,[{id:'U',incarnation:3}],{createId:()=> 'new'}),/copy_schema_unavailable/);
@@ -65,6 +84,7 @@ test('missing copy schema, unknown versions, ambiguous paths and opaque curves a
const invalid=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='incoming'?{...element,path:{type:'future'}}:element));assert.throws(()=>planDuplicate(invalid,registry,[target('F')],{createId:(()=>{let n=0;return()=>`unknown-${++n}`;})()}),/unsupported_copy_path/);
});
// frame.ports 对象数组只映射 nodeId,局部端口 ID/handle 及原数据不变;错误数组形状和重叠通配路径都拒绝。
test('frame port object arrays map child identities but retain local port IDs, handles and the source data',()=>{
const ports=[{id:'in',direction:'input',nodeId:'A',handle:'left'},{id:'out',direction:'output',nodeId:'B',handle:'port:nested'}],source=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(record=>record.id==='F'?{...record,data:{...record.data,ports}}:record));let next=0;
const plan=planDuplicate(source,registry,[target('F')],{createId:()=>`port-${++next}`}),data=applyChanges(source,plan.changes).elements.get(plan.elementIds.F).data;
+18 -1
View File
@@ -1,6 +1,23 @@
/**
* 持久化元数据订阅测试。durability 的脏状态、错误和关闭进度独立于业务 Root 发布。
* 内存 commit 可注入 quota 故障,观察者异常由错误回调接收,不应破坏其他观察者或保存状态。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
async function fixture(){let broken=false;const errors=[],engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:'p'},registry:createRegistry(builtinComponents()),document:createDocument('p',[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage:{async claim(scope){return{scope,active:true,fence:1,async release(){this.active=false;}};},async recover(){return null;},async commit(_leader,batch){if(broken)throw{code:22,message:'quota'};return{durableRootRevision:batch.image.rootRevision};},async readFile(){return;},close(){}},persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};}
// 建立没有业务网络操作的空文档引擎,先保存初始状态;任何意外的编译、发送、查询都会直接失败。
async function fixture(){let broken=false;const errors=[],engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:'p'},registry:createRegistry(builtinComponents()),document:createDocument('p',[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage:{
// 最小存储 owner 替身;关闭会释放其 active 标记。
async claim(scope){return{scope,active:true,fence:1,async release(){this.active=false;}};},
// 无历史镜像,保证每个用例从新的持久化元数据起步。
async recover(){return null;},
// 开关启用时抛出数字 code 的 quota 对象;否则确认此次镜像实际声明的修订。
async commit(_leader,batch){if(broken)throw{code:22,message:'quota'};return{durableRootRevision:batch.image.rootRevision};},
// 本组不使用本地文件,不额外产生资源任务。
async readFile(){return;},close(){}},
// 全部网络入口都故意抛错,保证测试只观察本地元数据而不是悄悄依赖网络同步。
persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};}
// runtime 修改先通知脏修订,保存后通知对应持久化修订;关闭也通知进度,但完成保存不再增加一次业务 Root。关闭后订阅为空清理函数。
test('durability subscribers observe dirty metadata, final durable cut and closure without another business Root',async()=>{const{engine}=await fixture(),states=[];const stop=engine.subscribeDurability(()=>states.push(engine.getDurability()));assert.strictEqual(engine.getDurability(),engine.getDurability());engine.editor.updateRuntime(previous=>({...previous}));const revision=engine.getSnapshot().rootRevision;assert.ok(states.some(state=>state.rootRevision>state.durableRootRevision));await engine.persist();assert.equal(states.at(-1).durableRootRevision,revision);assert.equal(engine.getSnapshot().rootRevision,revision);await engine.close();assert.ok(states.some(state=>state.closing&&!state.closed));assert.equal(states.at(-1).closed,true);stop();const noop=engine.subscribeDurability(()=>assert.fail('closed owner notified'));noop();});
// 一个监听器抛错不妨碍其他监听器看到 quota;数字 DOM 风格错误归一成字符串,保存失败保持脏状态,重试成功后清除错误。
test('storage failures remain observable and normalize numeric DOM-style codes without listener exceptions changing durability',async()=>{const f=await fixture();const states=[];f.engine.subscribeDurability(()=>{throw Error('observer');});f.engine.subscribeDurability(()=>states.push(f.engine.getDurability()));f.fail(true);f.engine.editor.updateRuntime(previous=>({...previous}));await assert.rejects(f.engine.persist());assert.equal(states.at(-1).error,'quota');assert.equal(typeof states.at(-1).error,'string');assert.ok(states.at(-1).durableRootRevision<states.at(-1).rootRevision);f.fail(false);await f.engine.persist();assert.equal(states.at(-1).error,undefined);assert.equal(states.at(-1).rootRevision,states.at(-1).durableRootRevision);assert.ok(f.errors.some(error=>error.message==='observer'));await f.engine.close();});
+14
View File
@@ -1,12 +1,19 @@
/**
* 按元素ID分桶的文档订阅测试。用lookup计数、加载覆盖及无change列表的ACK替换,检查局部通知不漏更新也不做无关读取。
* 模拟ports只实现测试需要的记录查找,不执行真实editor命令或网络请求。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createElementSources} from '../../src/lib/canvas-bindings/element-sources.ts';
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
/** 按count建元素表;lookup区分present/unloaded/absent并记录ID,edit携带field变化,emit可模拟整根替换。 */
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=[];
// 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'};}};
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',()=>{
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)));}
@@ -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);
});
// ACK没有changes清单但替换document:应检查活跃目标,深相等克隆保持0原快照,真正改1仍通知。
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 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',()=>{
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',()=>{
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',()=>{
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',()=>{
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',()=>{
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',()=>{
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 assert from 'node:assert/strict';
import {createDocument,validateElementRecord,validateDocument,validateDocumentSteps,createRegistry,defineComponent,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
/** probe记录包含合法几何与最小正文;可改字段用来制造尺寸、owner或引用错误。 */
const node=(id='a')=>({id,incarnation:1,ownerCanvasId:'root',componentId:'probe',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'hello'}});
/** 自定义validator带可切换allowed和调用计数,验证静态证明命中后schema仍按当前状态执行。 */
function setup(){let allowed=true,calls=0;const component=defineComponent({id:'probe',dataVersion:1,structure:'node',features:[],validate:data=>{calls++;return allowed&&typeof data.text==='string';}}),registry=createRegistry([...builtinComponents(),component]),document=createDocument('p',[{id:'root',parent:null}],[node()]);return{registry,document,allow:value=>{allowed=value;},calls:()=>calls};}
// 暂替Object.values检测是否重复遍历自有记录,finally恢复全局函数;schema计数、拒绝开关与墓碑仍实时检查。
test('owned record proofs skip repeated static traversal while schema and tombstones remain live checks',()=>{
const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const count=f.calls(),values=Object.values;try{Object.values=value=>{if(value===record||value===record.data)throw Error('owned static data enumerated again');return values(value);};validateElementRecord(record,f.registry);}finally{Object.values=values;}assert.equal(f.calls(),count+1);f.allow(false);assert.throws(()=>validateElementRecord(record,f.registry),/invalid_component_data/);f.allow(true);assert.throws(()=>validateElementRecord(record,f.registry,new Map([['a',1]])),/stale_incarnation/);
});
// 先验证合法图,再移走owner或端点;缓存的元素静态证明不能替代新文档关系校验。
test('static record proofs never certify owner membership or connection endpoints',()=>{
const f=setup(),b={...node('b'),geometry:{...node().geometry,x:200}},edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null},document=createDocument('p',[{id:'root',parent:null}],[node(),b,edge]);validateDocument(document,f.registry);assert.throws(()=>validateDocument({...document,canvases:new Map([['other',{id:'other',parent:null}]])},f.registry),/invalid_element/);assert.throws(()=>validateDocument({...document,elements:new Map([...document.elements].filter(([id])=>id!=='b'))},f.registry),/invalid_endpoint/);
});
// 浅冻结对象内部几何仍可变,getter也可换返回值;再次校验必须发现非法尺寸,且不得冻结调用方嵌套对象。
test('external shallow-frozen records and getters are revalidated and never frozen on behalf of callers',()=>{
const f=setup(),raw=Object.freeze(node());validateElementRecord(raw,f.registry);raw.geometry.width=0;assert.throws(()=>validateElementRecord(raw,f.registry),/invalid_geometry/);assert.equal(Object.isFrozen(raw.geometry),false);let geometry={...node().geometry};const getter=Object.freeze({...node(),get geometry(){return geometry;}});validateElementRecord(getter,f.registry);geometry={...geometry,width:-1};assert.throws(()=>validateElementRecord(getter,f.registry),/invalid_geometry/);
});
// 创建后破坏外部输入不影响editor记录;跳过schema的一次拓扑检查不能授予以后跳过schema的证明。
test('editor creations detach caller data; topology-only validation cannot bypass later schema checks',()=>{
const f=setup();let id=0;const editor=createEditor({document:f.document,registry:f.registry,createId:()=>`op-${++id}`}),incoming=node('new');assert.equal(editor.dispatch({type:'element.create',element:incoming}).status,'applied');incoming.geometry.width=-1;incoming.data.text=undefined;const record=editor.getSnapshot().document.elements.get('new');assert.equal(record.geometry.width,100);assert.equal(record.data.text,'hello');const fresh=createDocument('p',[{id:'root',parent:null}],[node('topology')]).elements.get('topology');f.allow(false);const calls=f.calls();validateElementRecord(fresh,f.registry,new Map(),false);assert.equal(f.calls(),calls);assert.throws(()=>validateElementRecord(fresh,f.registry),/invalid_component_data/);editor.dispose();
});
// 用空canvas、墓碑、缺端点及非法几何比较两种遍历结果,再检查合法迭代器的让步步数。
test('synchronous and cooperative validation preserve check order and the same rejection results',()=>{
const f=setup(),base=f.document,edge={id:'edge',incarnation:1,ownerCanvasId:'root',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'missing'},path:null},cases=[base,{...base,canvases:new Map()}, {...base,tombstones:new Map([['a',0]])},{...base,tombstones:new Map([['a',1]])},{...base,canvases:new Map([['root',{id:'root',parent:null}],['other',{id:'other',parent:null}]])},createDocument('p',[{id:'root',parent:null}],[node(),edge]),createDocument('p',[{id:'root',parent:null}],[{...node(),geometry:{...node().geometry,width:0}}])];
/** 将抛错规范为code/targets或valid,比较同步与协作遍历的拒绝结果,而非依赖异常对象引用。 */
const outcome=callback=>{try{callback();return'valid';}catch(error){return{code:error.code,targets:error.targets};}};
for(const document of cases)assert.deepEqual(outcome(()=>validateDocument(document,f.registry)),outcome(()=>{for(const _ of validateDocumentSteps(document,f.registry)){};}));
const iterator=validateDocumentSteps(base,f.registry);assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.deepEqual(iterator.next(),{done:false,value:undefined});assert.equal(iterator.next().done,true);
});
// 内置数据校验可复用证明,自定义同ID schema仍执行并保留this;失败校验和墓碑不能被缓存成成功。
test('only framework data-only validators can reuse a data proof; wrapped same-ID custom schemas stay live with their receiver',()=>{
const registry=createRegistry(builtinComponents()),input={...node(),componentId:'text',data:{title:'A',text:''}},record=createDocument('p',[{id:'root',parent:null}],[input]).elements.get('a');validateElementRecord(record,registry);const values=Object.values;try{Object.values=value=>{if(value===record.data)throw Error('framework data schema was repeated');return values(value);};validateElementRecord(record,registry);}finally{Object.values=values;}
let allowed=true,calls=0;const custom=defineComponent({id:'text',dataVersion:1,structure:'node',features:[],validate:function(data){calls++;assert.equal(this.id,'text');return allowed&&typeof data.title==='string';}}),other=createRegistry([custom]);validateElementRecord(record,other);allowed=false;assert.throws(()=>validateElementRecord(record,other),/invalid_component_data/);assert.equal(calls,2);assert.throws(()=>validateElementRecord(record,registry,new Map([['a',1]])),/stale_incarnation/);
const malformed=createDocument('p',[{id:'root',parent:null}],[{...input,data:{title:7,text:''}}]).elements.get('a');assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/);assert.throws(()=>validateElementRecord(malformed,registry),/invalid_component_data/);
});
// 把框架记录重新装进文档可保留引用,外部或浅冻结仿制记录须复制;切动态schema后仍能拒绝。
test('core-owned immutable element data survives document re-enveloping without aliasing caller-owned records',()=>{
const f=setup(),record=f.document.elements.get('a');validateElementRecord(record,f.registry);const next=createDocument('p',[...f.document.canvases.values()],[record]);assert.strictEqual(next.elements.get('a'),record);const external=node('external'),copy=createDocument('p',[{id:'root',parent:null}],[external]);assert.notStrictEqual(copy.elements.get('external'),external);external.data.text='later';assert.equal(copy.elements.get('external').data.text,'hello');const lookalike=Object.freeze({...record});assert.notStrictEqual(createDocument('p',[{id:'root',parent:null}],[lookalike]).elements.get('a'),lookalike);f.allow(false);assert.throws(()=>validateDocument(next,f.registry),/invalid_component_data/);
});
+17
View File
@@ -1,3 +1,7 @@
/**
* 未发送操作的本地抵消证明测试:完整创建到删除的封闭生命周期可以不产生请求,但必须保存身份高水位。
* 抵消需证明操作连续、没有观察者遗漏且没有可能执行的网络证据;资源仍可由撤销历史持有。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
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 { replayPending } from '../../src/lib/canvas-runtime/reconcile.ts';
import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
// 共享只读空基线与内置定义;各用例从该基线创建新工作文档,不修改基线本身。
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 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){
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);}
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',()=>{
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());
@@ -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);}
});
// 已有元素的后续修改不属于封闭生命周期:只抵消前两个操作,后续标题修改继续 pending 并可重放。
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 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');
});
// 分区入口、子 canvas 与成员一起创建再删除时可抵消;仍记录元素墓碑,不给服务端虚构 canvas 版本。
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]);
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',()=>{
const n=node();
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',()=>{
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'});
@@ -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/);
});
// 篡改已保存证明的归属、墓碑和基线时恢复校验拒绝;未来证明版本单独报告缺少解释器。
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();
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/);
});
// 即使文档操作已抵消,undo 历史仍引用图片;资源 pin 必须同时保留创建和删除历史的来源。
test('resource references remain pinned by undo history after the document operations retire',()=>{
const picture={...node('picture'),componentId:'image',data:{title:'picture',text:'',src:'asset:asset',assetId:'asset'}},f=fixture([[create(picture)],[remove(picture)]]);f.ledger.elide(f.proof());
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',()=>{
for(const id of ['new','constructor','__proto__']){
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',()=>{
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);
+63 -1
View File
@@ -1,48 +1,69 @@
/**
* ProjectEngine 的组合状态测试:文档、history、操作账本、ACK 基线由统一发布边界协调。
* 使用内存存储和持久化端口,验证保存顺序、未知结果、恢复会话、本地抵消与回执压缩;不等同于真实磁盘或服务端集成。
* Root 是不可变业务切点,durability 另行记录已确认保存到哪里;可见修改不等于安全离开。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDocument, applyChanges, builtinComponents, createRegistry } from '../../src/lib/canvas-core/index.ts';
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
import { encodeRecovery, decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
// 内置类型参与全部模型校验,避免测试通过空注册表绕开数据约束。
const registry = createRegistry(builtinComponents());
// 固定单 canvas、单文本元素的初始模型;后续场景只改变所需字段或生命周期。
const original = createDocument('project', [{ id: 'canvas', parent: null }], [{ id: 'a', incarnation: 1, structure: 'node', ownerCanvasId: 'canvas', componentId: 'text', dataVersion: 1, data: { title: 'a', text: '' }, geometry: { x: 0, y: 0, width: 100, height: 100, rotation: 0 } }]);
// 存储恢复作用域包含账户、workspace 和 project;目标引用还带 incarnation。
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, target = { id: 'a', incarnation: 1 };
// 用手动完成的 Promise 精确控制网络、编码或写盘何时越过屏障。
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
// 推进有限轮微任务;适用于保留在途请求而又要等调度器进入挂起点的测试。
const tick = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
// 内存 storage 持有独占 owner 和递增 fence;每次 commit 保存克隆镜像,并要求修订连续或重试同一修订。
function storageFixture() {
let image = null, owner = null, fence = 0;
const saved = [], fixture = {
broken: false, saved,
// 同一时刻只允许一个 owner;释放后再申请会获得新 fence,供重开测试辨别新存储所有者。
async claim(scope) { assert.equal(owner, null); const leader = { scope, fence: ++fence, active: true, async release() { owner = null; leader.active = false; } }; owner = leader; return leader; },
// 恢复返回镜像副本,调用方无法修改上一次已保存的对象。
async recover() { return image && structuredClone(image); },
// 先检查故障和当前 owner,再保存对应 Root;返回的 durableRootRevision 是引擎推进确认边界的证据。
async commit(leader, batch) { if (fixture.broken) throw new Error('quota'); assert.equal(owner, leader); assert.equal(leader.active, true); if (image) assert.ok(batch.image.rootRevision === image.rootRevision + 1 || batch.image.rootRevision === image.rootRevision); image = structuredClone(batch.image); saved.push(image); return { durableRootRevision: image.rootRevision }; },
async readFile() {}, close() {},
image: () => structuredClone(image), owner: () => owner,
};
return fixture;
}
// 服务替身按请求 ID 去重,只在首次 submit 应用修改;query 查询既有结果,overrides 可替换任一协议边界。
function persistenceFixture(overrides = {}) {
let server = original, version = 1;
const calls = [], receipts = new Map();
const fixture = {
calls, receipts,
// 把逻辑意图与当前参与版本冻结进 payload;测试编译器不执行真实 Foundry wire 格式压缩。
compile(intent, requestId, snapshot) { return { payload: { requestId, intent }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])) }; },
// 首次写入产生服务端快照并递增版本;重复同 ID 只返回该回执,不重复修改。
async submit(request) {
calls.push(['submit', request]);
if (!receipts.has(request.id)) { const { intent } = JSON.parse(request.body); server = applyChanges(server, intent.changes); version++; receipts.set(request.id, { document: server, versions: new Map([['canvas', version]]) }); }
return { outcome: 'applied', canonical: { receipt: request.id } };
},
// 记录查询并区分已知成功与 unresolved;没有回执不提供零写入保证。
async query(request) { calls.push(['query', request]); return receipts.has(request.id) ? { outcome: 'applied', canonical: { receipt: request.id } } : { outcome: 'unresolved' }; },
// 由已保存回执取规范快照;可在个别测试中替换为冲突数据,检查重放失败边界。
canonical(request) { return receipts.get(request.id); }, ...overrides,
};
return fixture;
}
// 统一装配 engine 并收集异步错误;extra 可覆盖时钟、ID、文档、registry 等默认装配选项。
async function open(storage, persistence, extra = {}) {
let next = 0; const errors = [];
const engine = await openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, persistence, createId: () => `identity-${++next}`, onError: error => errors.push(error), ...extra });
return { engine, errors };
}
// 本地命令一次发布文档、历史和账本;首个请求在途时继续移动,ACK 后基于新版本重放移动,最终两次修改都持久化。
test('local document/history/ledger publish together; ACK replays edits made while a request is in flight', async () => {
const storage = storageFixture(), port = persistenceFixture(), gate = deferred(), realSubmit = port.submit;
port.submit = async request => { if (!port.calls.length) await gate.promise; return realSubmit(request); };
@@ -67,6 +88,7 @@ test('local document/history/ledger publish together; ACK replays edits made whi
await engine.close();
});
// detach 只关闭视图,后台请求继续;引擎关闭再打开后恢复历史并查询原未知请求,ID 和冻结字节保持不变。
test('closing a view keeps jobs alive; closing/reopening the engine restores history and queries the original unknown request', async () => {
const storage = storageFixture(), port = persistenceFixture(), realSubmit = port.submit;
port.submit = async request => { await realSubmit(request); return { outcome: 'unresolved' }; };
@@ -86,6 +108,7 @@ test('closing a view keeps jobs alive; closing/reopening the engine restores his
await recovered.engine.close();
});
// 创建实际已执行但 ACK 丢失,随后删除仍依赖原创建;恢复先查询再发送独立删除,最终服务端只留墓碑而无幽灵元素。
test('create applied with a lost ACK followed by delete recovers the original request and leaves no server element',async()=>{
const storage=storageFixture(),port=persistenceFixture(),submit=port.submit;let lost=true,now=1000,next=0;
port.submit=async request=>{const status=await submit(request);if(lost){lost=false;return{outcome:'unresolved'};}return status;};
@@ -101,6 +124,7 @@ test('create applied with a lost ACK followed by delete recovers the original re
}finally{await engine.close();}
});
// quota 失败保留可见编辑但阻止网络;失败 close 保留存储 owner 并封闭编辑入口,存储恢复后重试 close 才释放。
test('failed local durability preserves editing, prevents sends, and failed close retains the recovery leader', async () => {
const storage = storageFixture(), port = persistenceFixture(), { engine, errors } = await open(storage, port);
await engine.persist(); storage.broken = true;
@@ -112,6 +136,7 @@ test('failed local durability preserves editing, prevents sends, and failed clos
storage.broken = false; await engine.close(); assert.equal(storage.owner(), null);
});
// 已知成功回执与后续几何重放冲突时保留本地内容、原始回执和 waiting 占用,不发送下一请求。
test('canonical replay conflict preserves working content and the known receipt without releasing the writer', async () => {
const storage = storageFixture(), port = persistenceFixture(), gate = deferred(), realSubmit = port.submit;
port.submit = async request => { await gate.promise; return realSubmit(request); };
@@ -129,6 +154,7 @@ test('canonical replay conflict preserves working content and the known receipt
await engine.close();
});
// 编码解码恢复只读集合及历史;缺失历史引用和自依赖均被拒绝,另一个镜像副本的修改不改变已恢复内容。
test('recovery codec restores immutable maps and rejects broken history/dependency references', async () => {
const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port);
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'encoded' } }); await engine.settle();
@@ -143,6 +169,7 @@ test('recovery codec restores immutable maps and rejects broken history/dependen
await engine.close();
});
// 无未决写入时可接纳更新规范基线并保留历史;旧版本刷新被拒绝,不回退新几何。
test('a fresh canonical read replaces a clean recovered base, keeps history and refuses older versions', async () => {
const storage = storageFixture(), port = persistenceFixture(), first = await open(storage, port);
first.engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'local' } }); await first.engine.settle(); await first.engine.close();
@@ -156,6 +183,7 @@ test('a fresh canonical read replaces a clean recovered base, keeps history and
await reopened.engine.close();
});
// 恢复先等已入场请求完成再读取完整基线;准备期间封闭编辑、生成和刷新,预览不发布,apply 才发布并落盘且不改原历史引用。
test('recovery waits for admitted writes, reads a complete base, fences edits and publishes without changing history', {timeout:5000}, async()=>{
const storage=storageFixture(),port=persistenceFixture(),response=deferred(),realSubmit=port.submit,readGate=deferred(),readEntered=deferred();
let reads=0;port.submit=async request=>{await response.promise;return realSubmit(request);};
@@ -173,6 +201,7 @@ test('recovery waits for admitted writes, reads a complete base, fences edits an
await engine.close();
});
// 未知回执不能靠全量读跳过确认;prepareRecovery 失败后保留原请求与本地状态,并解除临时编辑屏障。
test('unknown receipts prevent a recovery read and preserve the frozen request identity',async()=>{
const storage=storageFixture();let reads=0;const port=persistenceFixture({async submit(){return{outcome:'unresolved'};},async read(){reads++;return{document:original,versions:new Map([['canvas',1]])};}});
const{engine}=await open(storage,port);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();
@@ -182,6 +211,7 @@ test('unknown receipts prevent a recovery read and preserve the frozen request i
assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'allowed'}}).enabled,true);await engine.close();
});
// 冲突预览不覆盖本地内容或历史,直接 apply 被拒绝;重复 cancel 安全,取消后恢复普通编辑。
test('recovery conflict previews preserve local data/history and cancel releases the edit fence',async()=>{
const remote=applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);
const storage=storageFixture(),port=persistenceFixture({async submit(){return{outcome:'not-applied',error:'version_conflict'};},async read(){return{document:remote,versions:new Map([['canvas',2]])};}});
@@ -191,6 +221,7 @@ test('recovery conflict previews preserve local data/history and cancel releases
await session.cancel();await session.cancel();assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'more'}}).enabled,true);await engine.close();
});
// 恢复 Root 已发布但保存失败时保留编辑屏障;重试保存同一 Root,不再创建一次恢复修订。
test('failed recovery durability retains its fence and retries the same published root',async()=>{
const storage=storageFixture(),port=persistenceFixture({async read(){return{document:original,versions:new Map([['canvas',1]])};}}),{engine}=await open(storage,port);
const session=await engine.prepareRecovery();storage.broken=true;await assert.rejects(session.apply(),/quota/);const revision=engine.getSnapshot().rootRevision;
@@ -199,6 +230,7 @@ test('failed recovery durability retains its fence and retries the same publishe
await engine.close();
});
// 关闭引擎 abort 恢复读取并释放 owner;端口仍返回迟到结果也必须拒绝,不能创建恢复会话。
test('closing the engine aborts a recovery read and rejects a late canonical response',async()=>{
const storage=storageFixture(),response=deferred();let signal;
const port=persistenceFixture({async read(input){signal=input;return response.promise;}}),{engine}=await open(storage,port);
@@ -206,6 +238,7 @@ test('closing the engine aborts a recovery read and rejects a late canonical res
response.resolve({document:original,versions:new Map([['canvas',1]])});await assert.rejects(preparing,/engine_closing/);assert.equal(storage.owner(),null);
});
// 恢复读和普通 refresh 都拒绝同版本内容改写;另外直接检查墓碑回退与生命周期变化,不凭版本增加就接受旧生命期。
test('complete recovery reads reject same-version rewrites and lifecycle regression before creating a session',async()=>{
const remote=applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);
const storage=storageFixture(),port=persistenceFixture({async read(){return{document:remote,versions:new Map([['canvas',1]])};}}),{engine}=await open(storage,port);
@@ -220,6 +253,7 @@ test('complete recovery reads reject same-version rewrites and lifecycle regress
await engine.close();
});
// 同一事务先创建再删除,basePresence 必须保留最初 absent;本地留下墓碑但无需发送,也不能伪造 ACK 墓碑。
test('one transaction captures the first presence baseline even when its created element is later deleted', async () => {
const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port);
try {
@@ -239,6 +273,7 @@ test('one transaction captures the first presence baseline even when its created
});
// 完全未发送的创建删除在本地抵消,仍保留两个历史单元和墓碑;无关 ACK 与重开不丢它,撤销恢复为新 incarnation。
test('unsent create/delete retires locally, survives unrelated ACK and reopening, and undo restores a fresh incarnation', async () => {
const storage = storageFixture(), port = persistenceFixture(); let next=0, engine;
const reopen=async()=>{if(engine)await engine.close();engine=(await open(storage,port,{createId:()=>`elision-${++next}`})).engine;};
@@ -260,6 +295,7 @@ test('unsent create/delete retires locally, survives unrelated ACK and reopening
});
// 本地抵消证据、工作墓碑和历史内容必须相互匹配;伪造或缺失会拒绝,未来解释版本单独报告不可解释。
test('recovery rejects forged or unsupported local completion proofs and missing working lifecycle marks', async () => {
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port);
try {
@@ -277,6 +313,7 @@ test('recovery rejects forged or unsupported local completion proofs and missing
});
// 保存失败的本地抵消只在内存成立,不虚构远程回执;重试保存并重开后仍使用原抵消证据且无网络请求。
test('failed durability keeps a local elision in memory without inventing a network success, then persists the same proof', async () => {
const storage=storageFixture(),port=persistenceFixture();let engine=(await open(storage,port)).engine;
try{
@@ -290,6 +327,7 @@ test('failed durability keeps a local elision in memory without inventing a netw
});
// 反复未发送的 undo/redo 仍可本地抵消并保留历次墓碑;首次真正发布恢复时使用第四代,跳过已取消代次。
test('repeated unsent undo/redo pairs remain local and the first published restore skips all canceled incarnations',async()=>{
const storage=storageFixture(),port=persistenceFixture();let next=0,engine=(await open(storage,port,{createId:()=>`repeat-${++next}`})).engine;
try{
@@ -303,6 +341,7 @@ test('repeated unsent undo/redo pairs remain local and the first published resto
});
// 即使端口支持批量编译,也先抵消后面的闭合创建删除;仅发送前面真实编辑,批量编译器若收到闭合生命期就使测试失败。
test('batch-capable persistence sends an earlier real edit separately and never freezes the later closed lifetime',async()=>{
const storage=storageFixture(),port=persistenceFixture({compileBatch(){throw Error('closed lifetime entered the wire batch');}}),{engine,errors}=await open(storage,port);
try{
@@ -312,73 +351,96 @@ test('batch-capable persistence sends an earlier real edit separately and never
}finally{await engine.close();}
});
// 不主动触发重试的假时钟;定时器仅返回 token,使持久化竞争用例集中观察显式保存和在途网络。
const idleClock=()=>({now:()=>0,setTimer:()=>Symbol('idle'),clearTimer(){}});
// 八次编辑各保留业务 Root,保存只按确认前缀发布 durableGeneration 回声;派发前镜像必须已含 dispatch-marked 请求和原体。
test('a pending write train preserves every business root and publishes one durable-generation echo per captured prefix',async()=>{
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let sends=0;port.submit=async request=>{sends++;const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));await network.promise;return{outcome:'unresolved'};};try{
await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;let held=false,markers=[],through=start.runtime.ledger.durableGeneration;const unsubscribe=engine.editor.subscribe((_,root)=>{if(root.runtime.ledger.durableGeneration>through){markers.push(root.runtime.ledger.durableGeneration);through=root.runtime.ledger.durableGeneration;}});
storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}return commit(...args);};
for(let i=0;i<8;i++)assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'train-'+i}}).status,'applied');
// 保存队列挂起时记录调用方目标 generation;调度器可能再发布冻结请求,因此还要核对后续捕获前缀的确认回声。
const requested=engine.getSnapshot().runtime.ledger.generation,saving=engine.persist();await entered.promise;await tick();const expected=engine.getSnapshot().runtime.ledger.generation,queued=engine.getSnapshot().rootRevision-start.rootRevision;assert.equal(sends,0);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);gate.resolve();await saving;unsubscribe();
assert.deepEqual(markers,[...new Set([requested,expected])]);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,expected);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);const rows=storage.saved.slice(saved);assert.equal(rows.length,queued+markers.length);assert.deepEqual(rows.map(image=>image.rootRevision),Array.from({length:queued+markers.length},(_,i)=>start.rootRevision+i+1));assert.deepEqual(rows.slice(0,8).map(image=>image.payload.document.elements[0].data.title),Array.from({length:8},(_,i)=>'train-'+i));assert.equal(decodeRecovery(storage.image(),registry).runtime.ledger.durableGeneration,expected);await tick();assert.equal(sends,1);
}finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();}
});
// 连续保存第四次失败时只确认前三个切点,内存仍显示最后编辑;重试从完整未确认后缀继续,修订不得跳号且不能提前发送。
test('a later quota failure records only confirmed generation and retries the full continuous suffix',async()=>{
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let sends=0;port.submit=async request=>{sends++;const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));await network.promise;return{outcome:'unresolved'};};try{
await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;let writes=0,broken=true;storage.commit=async(...args)=>{writes++;if(writes===1){entered.resolve();await gate.promise;}if(broken&&writes===4)throw Error('quota-later');return commit(...args);};
for(let i=0;i<8;i++)engine.editor.dispatch({type:'element.data',target,patch:{title:'partial-'+i}});const saving=engine.persist();await entered.promise;await tick();const expected=engine.getSnapshot().runtime.ledger.generation,queued=engine.getSnapshot().rootRevision-start.rootRevision;assert.equal(sends,0);gate.resolve();await assert.rejects(saving,/quota-later/);const confirmed=storage.saved.at(-1);assert.equal(storage.saved.length-saved,3);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,confirmed.payload.runtime.ledger.generation);assert.ok(engine.getSnapshot().runtime.ledger.durableGeneration<expected);assert.equal(engine.getDurability().durableRootRevision,confirmed.rootRevision);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'partial-7');assert.equal(sends,0);
// 失败前缀已被确认,重试只补连续后缀和确认 Root;不能因内存已显示最后编辑就跳过缺失修订。
broken=false;await engine.persist();assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,expected);const rows=storage.saved.slice(saved);assert.deepEqual(rows.map(image=>image.rootRevision),Array.from({length:rows.length},(_,i)=>start.rootRevision+i+1));assert.equal(decodeRecovery(storage.image(),registry).document.elements.get('a').data.title,'partial-7');assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);
}finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();}
});
// commit 返回超前修订是无效证据,即使内存存储已经写入也不能推进确认;重试必须提交与原切点完全相同的镜像。
test('an invalid receipt cannot advance confirmed generation and the original cut remains retryable',async()=>{
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let bad=true,held=false,sends=0;port.submit=async()=>{sends++;await network.promise;return{outcome:'unresolved'};};try{
await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}const receipt=await commit(...args);return bad?{durableRootRevision:receipt.durableRootRevision+1}:receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'receipt-test'}});const saving=engine.persist();await entered.promise;await tick();gate.resolve();await assert.rejects(saving,/invalid_durable_receipt/);assert.equal(engine.getDurability().durableRootRevision,start.rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);assert.equal(sends,0);const first=storage.saved[saved];bad=false;await engine.persist();const retry=storage.saved[saved+1];assert.deepEqual(retry,first);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,engine.getSnapshot().runtime.ledger.generation);
await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}const receipt=await commit(...args);return bad?{durableRootRevision:receipt.durableRootRevision+1}:receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'receipt-test'}});const saving=engine.persist();await entered.promise;await tick();gate.resolve();await assert.rejects(saving,/invalid_durable_receipt/);assert.equal(engine.getDurability().durableRootRevision,start.rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);assert.equal(sends,0);
// 底层实际写入与上层认可证据分开:坏回执未获认可,下一次必须重试同一镜像,而不是假设可以跳到下个 Root。
const first=storage.saved[saved];bad=false;await engine.persist();const retry=storage.saved[saved+1];assert.deepEqual(retry,first);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,engine.getSnapshot().runtime.ledger.generation);
}finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();}
});
// 保存成功后因 owner 失效而无法发布确认时保留已验证回执;恢复 owner 后只补确认 Root,监听器重入 persist 也不能并发写盘或重写旧切点。
test('a failed durability publication retains its verified receipt through retry without rewriting saved business cuts',async()=>{
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),network=deferred(),commit=storage.commit;let lose=true;port.submit=async()=>{await network.promise;return{outcome:'unresolved'};};try{
await engine.persist();const leader=storage.owner(),saved=storage.saved.length;storage.commit=async(...args)=>{const receipt=await commit(...args);if(lose&&args[1].image.rootRevision===engine.getSnapshot().rootRevision){lose=false;leader.active=false;}return receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'publication-retry'}});await assert.rejects(engine.persist(),/storage_leader_lost/);const confirmed=storage.saved.at(-1);assert.equal(engine.getDurability().durableRootRevision,confirmed.rootRevision);assert.ok(confirmed.payload.runtime.ledger.generation>engine.getSnapshot().runtime.ledger.durableGeneration);const count=storage.saved.length;leader.active=true;let activeWrites=0,maxWrites=0;const nested=[];storage.commit=async(...args)=>{activeWrites++;maxWrites=Math.max(maxWrites,activeWrites);try{await tick();return await commit(...args);}finally{activeWrites--;}};const unsubscribe=engine.editor.subscribe((_,root)=>{if(root.runtime.ledger.durableGeneration===confirmed.payload.runtime.ledger.generation)nested.push(engine.persist());});await engine.persist();await Promise.all(nested);unsubscribe();assert.equal(maxWrites,1);assert.equal(storage.saved.length,count+1);assert.equal(storage.saved.at(-1).rootRevision,confirmed.rootRevision+1);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,confirmed.payload.runtime.ledger.generation);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.ok(storage.saved.length>saved);
}finally{const leader=storage.owner();if(leader)leader.active=true;network.resolve();storage.commit=commit;await engine.close();}
});
// 空写入泵刚排空后再产生编辑,新的 persist 调用仍必须等自己的目标 Root,不能复用旧的已完成等待。
test('a caller arriving after an empty writer drain still waits for its own newly published Root', {timeout:5000},async()=>{
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;port.submit=async()=>{await network.promise;return{outcome:'unresolved'};};try{
await engine.persist();storage.commit=async(...args)=>{entered.resolve();await gate.promise;return commit(...args);};const empty=engine.persist();let edited,required;await new Promise(resolve=>queueMicrotask(()=>{engine.editor.dispatch({type:'element.data',target,patch:{title:'after-empty-writer'}});required=engine.getSnapshot().rootRevision;edited=engine.persist();resolve();}));assert.equal(await Promise.race([entered.promise.then(()=>'write'),edited.then(()=>'returned')]),'write');assert.ok(engine.getDurability().durableRootRevision<required);gate.resolve();await Promise.all([empty,edited]);assert.ok(engine.getDurability().durableRootRevision>=required);assert.equal(storage.image().payload.document.elements[0].data.title,'after-empty-writer');
}finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();}
});
// 已保存的 dispatch 前缀可以发送,无需等待更晚的本地 Root;但显式 persist 仍等待它捕获的目标修订全部保存。
test('a durable dispatch prefix can send while a later local Root is still waiting for storage', {timeout:10000},async()=>{
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),body=deferred(),bodyEntered=deferred(),later=deferred(),laterEntered=deferred(),sent=deferred(),network=deferred(),commit=storage.commit;let bodyCut,after=Infinity,flushed=false;port.submit=async request=>{assert.ok(storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));sent.resolve();await network.promise;return{outcome:'unresolved'};};try{
await engine.persist();storage.commit=async(...args)=>{const image=args[1].image;if(bodyCut===undefined&&image.payload.runtime.ledger.requests.some(([,row])=>row.phase==='dispatch-marked')){bodyCut=image.rootRevision;bodyEntered.resolve();await body.promise;}if(image.rootRevision>after){laterEntered.resolve();await later.promise;}return commit(...args);};engine.editor.dispatch({type:'element.data',target,patch:{title:'first'}});const saving=engine.persist().then(()=>{flushed=true;});await bodyEntered.promise;after=engine.getSnapshot().rootRevision;engine.editor.dispatch({type:'element.geometry',target,patch:{x:42}});body.resolve();await laterEntered.promise;await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('durable request waited for a later Root')),2000);sent.promise.then(()=>{clearTimeout(timer);resolve();},reject);});assert.equal(flushed,false);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,42);later.resolve();await saving;assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);
}finally{body.resolve();later.resolve();network.resolve();storage.commit=commit;await engine.close();}
});
// 可选成功回执摘要必须与 ACK、history、ledger 一起发布;每个可恢复切点保持一致,重开不重传且冻结 body 不变。
test('an opted-in resolved receipt summary publishes with ACK/history/ledger and reopens without retransmission',async()=>{
const storage=storageFixture(),port=persistenceFixture({compactResolvedReceipt(request,receipt,applied){assert.equal(request.phase,'resolved');assert.equal(request.outcome,'applied');assert.equal(receipt.receipt,request.id);assert.equal(applied.document.elements.get('a').data.title,'summary');return{localReceiptVersion:1,id:request.id,versions:[...applied.versions]};}});let{engine}=await open(storage,port);
try{engine.editor.dispatch({type:'element.data',target,patch:{title:'summary'}});await engine.settle();const snapshot=engine.getSnapshot(),request=[...snapshot.runtime.ledger.requests.values()][0],body=request.body;assert.equal(request.phase,'resolved');assert.deepEqual(snapshot.runtime.receipts.get(request.id),{localReceiptVersion:1,id:request.id,versions:[['canvas',2]]});assert.equal(snapshot.history.undo.length,1);for(const image of storage.saved){const restored=decodeRecovery(image,registry),saved=restored.runtime.ledger.requests.get(request.id);if(restored.runtime.receipts.get(request.id)?.localReceiptVersion===1){assert.equal(saved.phase,'resolved');assert.equal(restored.document.elements.get('a').data.title,'summary');assert.equal(restored.history.undo.length,1);}if(saved)assert.equal(saved.body,body);}await engine.close();({engine}=await open(storage,port));await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,body);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'summary');assert.equal(port.calls.filter(([kind])=>kind==='submit').length,1);
}finally{await engine.close();}
});
// 摘要准备后最终模型校验失败,必须保留原始成功回执和 waiting 占用;校验恢复后查询修复,不第二次 submit。
test('a failed final publication keeps the raw receipt and waiting claim instead of the prepared summary',async()=>{
let blocked=false,compactions=0;const custom=createRegistry(builtinComponents().map(definition=>definition.id==='text'?{...definition,validate:data=>typeof data.title==='string'&&typeof data.text==='string'&&(!blocked||data.title!=='local')}:definition)),storage=storageFixture(),port=persistenceFixture({compactResolvedReceipt(request){compactions++;if(compactions===1)blocked=true;return{summary:request.id};}});let{engine}=await open(storage,port,{registry:custom});
try{engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();const snapshot=engine.getSnapshot(),request=[...snapshot.runtime.ledger.requests.values()][0];assert.equal(compactions,1);assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');assert.deepEqual(snapshot.runtime.receipts.get(request.id),{receipt:request.id});assert.equal(snapshot.document.elements.get('a').data.title,'local');assert.equal(snapshot.history.undo.length,1);blocked=false;await engine.close();({engine}=await open(storage,port,{registry:custom}));await engine.retry();await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.deepEqual(engine.getSnapshot().runtime.receipts.get(request.id),{summary:request.id});assert.equal(port.calls.filter(([kind])=>kind==='submit').length,1);assert.ok(port.calls.some(([kind])=>kind==='query'));
}finally{blocked=false;await engine.close();}
});
// 未提供、抛错或返回空值的压缩器不影响合法 ACK;保留原始回执,只有失败压缩器通过错误回调报告。
test('receipt compaction is optional and invalid codecs cannot block a valid ACK or discard its raw payload',async()=>{
for(const compactResolvedReceipt of [undefined,()=>{throw Error('compact failed');},()=>undefined]){const storage=storageFixture(),port=persistenceFixture(compactResolvedReceipt?{compactResolvedReceipt}:{}),{engine,errors}=await open(storage,port);try{engine.editor.dispatch({type:'element.data',target,patch:{title:'keep raw'}});await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.phase,'resolved');assert.deepEqual(engine.getSnapshot().runtime.receipts.get(request.id),{receipt:request.id});assert.equal(engine.getSnapshot().runtime.conflicts.size,0);assert.equal(errors.length,compactResolvedReceipt?1:0);}finally{await engine.close();}}
});
// 大模型编码在 scheduler.yield 挂起期间继续编辑,首个镜像仍对应捕获的旧 Root;随后镜像按修订连续保存,冻结请求未落盘前不发送。
test('encoding yields preserve the captured Root, ordered durable prefix and send barrier', {timeout:15000},async()=>{
const previous=Object.getOwnPropertyDescriptor(globalThis,'scheduler'),entered=deferred(),resume=deferred();let hold=false,paused=false,yields=0;
Object.defineProperty(globalThis,'scheduler',{configurable:true,value:{async yield(){yields++;if(hold&&!paused){paused=true;entered.resolve();await resume.promise;}}}});
let engine;
try{
// 超过编码让出阈值的大模型;每个记录合法且 ID 独立,避免用编码失败替代调度竞争。
const rows=Array.from({length:17000},(_,i)=>({...original.elements.get('a'),id:i===0?'a':`encoding-${i}`}));
const document=createDocument('project',[{id:'canvas',parent:null}],rows),storage=storageFixture(),port=persistenceFixture({async submit(request){port.calls.push(['submit',request]);const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));return{outcome:'unresolved'};}});
({engine}=await open(storage,port,{document,clock:idleClock()}));await engine.persist();
const start=engine.getSnapshot().rootRevision,saved=storage.saved.length;hold=true;
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'captured-first'}}).status,'applied');
// 首个 persist 捕获旧 Root 后暂停编码,再允许第二次编辑产生新 Root;两次保存必须各自对应其捕获内容。
const captured=engine.getSnapshot(),first=engine.persist();await entered.promise;
assert.equal(storage.saved.length,saved);assert.equal(port.calls.length,0);
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'edited-during-yield'}}).status,'applied');
const second=engine.persist();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'edited-during-yield');
resume.resolve();await Promise.all([first,second]);
// 比较完整首镜像与同步 encodeRecovery(captured),验证让出主线程后没有混入后来编辑;同时检查修订序列无空洞。
const cuts=storage.saved.slice(saved);assert.ok(yields>0);
assert.deepEqual(cuts.map(cut=>cut.rootRevision),Array.from({length:cuts.length},(_,i)=>start+i+1));
assert.deepEqual(cuts[0],encodeRecovery(captured));
+11
View File
@@ -1,21 +1,31 @@
/**
* 画布入口的 canonical-first 选择测试。已迁移项目直接进入 headless,只有明确迁移需求或未部署接口可走旧快照。
* 权限失败不能降级成旧数据读取;入口只保留已验证迁移材料,不自动发迁移请求。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
// UUID 工厂区分 board、workspace、profile,供入口解析和 service 身份验证。
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
// 模拟已解析 board,workspaceRef/boardRef 以明确 ID 输入,避免把 slug 解析与模式选择混成同一个断言。
const board = { id: id(1), workspace_id: id(2), kind: 'board', title: 'Board', version: 1 };
// 当前账户与内置 registry,transport 工厂必须收到该 profile 身份。
const profile = { id: id(3) }, registry = createRegistry(builtinComponents());
// request 提供 profile/board/旧 canvas,transport 返回注入 canonical 响应;所有路径记录到 calls 检查不应发生的 fallback。
function fixture(response) {
const calls = [], request = async path => { calls.push(path); if (path === 'profile') return profile; if (path.endsWith('/canvas')) return { version: 1, document: { schema_version: 2, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; return board; };
return { calls, options: { request, workspaceRef: board.workspace_id, boardRef: board.id, registry, transport: accountId => { assert.equal(accountId, profile.id); return async input => { calls.push(input.path); return response; }; } } };
}
// canonical 完整模型已可用时只进入 headless,不能再读取旧 canvas 快照制造第二份文档来源。
test('canonical-first entry never reads a legacy snapshot for an adopted project', async () => {
const f = fixture({ status: 200, body: { model: { documentFormatVersion: 1, projectId: board.id, complete: true, canvases: [{ id: board.id, parent: null }], elements: [], tombstones: {}, versions: { [board.id]: 1 }, removedCanvasIds: [] } } });
const result = await loadCanvasEntry(f.options); assert.equal(result.mode, 'headless'); assert.equal(result.model.document.projectId, board.id);
assert.equal(f.calls.some(path => path.endsWith('/canvas')), false);
});
// 404 未部署或合法 migration_required 允许读取旧快照;403 权限失败直接报错,绝不退回 legacy。
test('explicit migration requirement or missing deployment keeps legacy reads; permission failure does not fall back', async () => {
for (const response of [{ status: 404, body: { error: 'not_found' } }, { status: 409, body: { error: 'canonical_migration_required', root_id: board.id, document_format_version: 1, source_versions: { [board.id]: 1 } } }]) {
const f = fixture(response); assert.equal((await loadCanvasEntry(f.options)).mode, 'legacy'); assert.equal(f.calls.filter(path => path.endsWith('/canvas')).length, 1);
@@ -24,6 +34,7 @@ test('explicit migration requirement or missing deployment keeps legacy reads; p
await assert.rejects(() => loadCanvasEntry(f.options), /forbidden/); assert.equal(f.calls.some(path => path.endsWith('/canvas')), false);
});
// legacy 入口保留验证过的 root/source 版本向量及 profile/snapshot,不发 migrate;404 未部署则不提供虚构迁移材料。
test('legacy entry retains verified migration root and the full source vector without offering migration for an unavailable API',async()=>{
const child=id(4),f=fixture({status:409,body:{error:'canonical_migration_required',root_id:board.id,document_format_version:1,source_versions:{[board.id]:3,[child]:7}}});
const result=await loadCanvasEntry(f.options);
+30 -5
View File
@@ -1,24 +1,40 @@
/** Remote authenticated HTTP/DB integration. Only the model output is a fixture. */
/**
* 由 Foundry 远程集成入口调用的 AI 场景:HTTP、回执查询和数据库链路真实运行,模型输出使用 fixture。
* open、transport、资源端口、故障注入和时钟均由调用方提供;本文件不会独立启动服务或读取登录凭据。
* 依次验证丢失受理响应、取消恢复、生成期间的内容冲突,以及受管图片和复制复用资源。
*/
import assert from 'node:assert/strict';
import {randomUUID,createHash} from 'node:crypto';
// 组织整套有状态集成场景并返回已通过的检查标记;任一断言失败都进入 finally 关闭引擎。
export async function verifyFoundryAI({open,transport,rootId,imageId,assets,calls,tick,readDisplay,advance,loseAdmission,loseAction}){
// 使用调用方准备的 workspace 和根 material 定位测试服务,不自行创建项目或切换身份。
const path=`workspaces/${process.env.FOUNDRY_TEST_WORKSPACE}/materials/${rootId}/canvas/ai`;
// 通过真实 HTTP 操作 fixture 执行屏障和计数器;每次调用有 15 秒超时且要求 200。
const control=async action=>{const response=await transport({method:'POST',path:path+'/fixture/'+action,signal:AbortSignal.timeout(15_000)});assert.equal(response.status,200,action+': '+JSON.stringify(response.body));return response.body;};
// 从共享调用记录筛选某类 POST,用次数与冻结请求体判断恢复是否意外重复了原动作。
const posts=suffix=>calls.filter(call=>call.method==='POST'&&call.path.endsWith(suffix));
// 保存当前引擎实例;重开时替换该引用,后续断言读取恢复后的同一项目。
let engine=await open();const signal=new AbortController().signal;
// 正常关闭释放资源与存储占用,推进调用方时钟后重新打开并等待恢复流程收敛。
const reopen=async()=>{await engine.close();advance();engine=await open();await engine.settle();};
// 显式 retry 后排空调用方可推进的轮询定时器;最多 100 轮,防止轮询异常令远程检查无限等待。
const poll=async()=>{await engine.retry();for(let turns=0;turns<100;turns++){if(!tick())return;await engine.settle();}assert.fail('AI timer drain exceeded its bound');};
try{
// 先确认测试服务提供文本和图片 fixture 模型;缺少能力即失败,不降级为本地假结果。
const catalog=await engine.models(rootId,signal);assert.ok(catalog.models.some(model=>model.id==='fixture-text'));assert.ok(catalog.models.some(model=>model.id==='fixture-image'));
// 创建独立文本目标,附带 extension 字段;结果合并必须保留未知扩展及用户修改的几何。
const target={id:randomUUID(),incarnation:1};
assert.equal(engine.editor.dispatch({type:'element.create',element:{id:target.id,incarnation:1,ownerCanvasId:rootId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Real AI adapter',text:'before generation',extension:{keep:true}},geometry:{x:0,y:0,width:100,height:80,rotation:0}}}).status,'applied');await engine.settle();
const input={target,boardId:rootId,modelId:'fixture-text',prompt:'Fixture generation',parameters:{},propagate:false};
// 统一断言 startRun 在本地获准并等待受理链收敛;此时并不代表模型已经执行完毕。
const begin=async value=>{const result=engine.startRun(value);assert.equal(result.status,'applied',JSON.stringify(result));await engine.settle();return result;};
// 通过受理操作 ID 找回对应 RunRecord,避免假设任务 ID 等于操作 ID。
const byOperation=id=>[...engine.getSnapshot().runtime.runs.values()].find(run=>run.operationId===id);
// 记录本场景开始前的调用基数,末尾核对生成次数和图片复制是否引入额外上传。
const initialStarts=posts('/ai/runs').length,initialUploads=posts('/assets').length;
// A committed admission whose response was lost must be queried after reopen.
// 服务端已提交受理后丢弃响应:重开必须查询原请求,保持原 body,不能第二次启动生成。
loseAdmission();const first=await begin(input),admission=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.start'&&request.operationIds.includes(first.operationIds[0]));
assert.equal(admission.phase,'waiting');assert.equal(posts('/ai/runs').at(-1).response?.httpStatus,202,'must lose a committed admission, not a rejection');const admissionCount=posts('/ai/runs').length;
await reopen();assert.equal(posts('/ai/runs').length,admissionCount);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(admission.id).body,admission.body);
@@ -26,6 +42,7 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
const recovered=engine.getSnapshot().runtime.ledger.requests.get(admission.id);
assert.equal(recovered.phase,'resolved','AI admission recovery: '+JSON.stringify({phase:recovered.phase,error:recovered.error,requestId:admission.id,operationId:first.operationIds[0],responses:calls.filter(call=>call.path.endsWith('/ai/runs')||call.path.endsWith('/ai/operations/'+admission.id)).map(call=>({method:call.method,response:call.response}))}));
const firstRun=byOperation(first.operationIds[0]);assert.ok(firstRun,'resolved admission must publish a RunRecord');assert.equal(firstRun.status,'queued');
// 受理恢复后先移动元素再执行生成;服务结果只能更新文字,必须保留这次移动及扩展数据。
assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:77}}).status,'applied');await engine.settle();
await control('execute');await poll();
const generatedText=engine.getSnapshot().runtime.runs.get(firstRun.id);
@@ -33,7 +50,7 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'headless fixture output');assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,77);
assert.deepEqual(engine.getSnapshot().document.elements.get(target.id).data.extension,{keep:true});
// Queued cancellation is durable and does not execute the generation transport.
// 排队任务取消的响应丢失后查询恢复;执行次数不增加,并排空取消事件游标后才开启下一任务。
const second=await begin({...input,prompt:'Fixture cancellation'}),secondRun=byOperation(second.operationIds[0]),beforeCancel=(await control('stats')).executions;
loseAction();assert.equal(engine.cancelRun(secondRun.id).status,'applied');await engine.settle();
const cancellation=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.cancel'&&request.runId===secondRun.id);assert.equal(cancellation.phase,'waiting');const cancellationCount=posts('/cancel').length;
@@ -41,16 +58,19 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
await poll();assert.equal(engine.getSnapshot().runtime.runs.get(secondRun.id).hasMore,false,'drain the cancelled run event cursor before another admission');
assert.equal((await control('stats')).executions,beforeCancel);
// Edit while the provider is held, then retain and explicitly apply its output.
// 用服务端屏障挂起 provider,期间提交用户文字;结果发生冲突后保留输出,等待用户显式选择应用。
const third=await begin({...input,prompt:'Fixture conflict'}),thirdRun=byOperation(third.operationIds[0]);await control('arm');
// 并行启动执行并把错误转成结果对象,确保解除屏障后能统一等待和报告,避免未处理的 Promise 拒绝。
const executing=control('execute').then(value=>({value}),error=>({error}));
try{
await control('wait');
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'user edit while executing'}}).status,'applied');await engine.settle();
// 无论中途断言是否通过,都先解除 provider 屏障,避免测试留下永久挂起的执行。
}finally{await control('release');}
const completed=await executing;if(completed.error)throw completed.error;await poll();
const conflict=engine.getSnapshot().runtime.runs.get(thirdRun.id);assert.equal(conflict.status,'conflict');assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'user edit while executing');
const outputId=conflict.outputEvents[0]?.output.output_id;assert.equal(typeof outputId,'string');
// 再次模拟成功动作丢响应;先撤销再重开,通过查询确认原应用,随后同步撤销,redo 只恢复已有内容。
loseAction();assert.equal(engine.applyRunOutput(conflict.id,target,outputId).status,'applied');await engine.settle();
const application=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.apply'&&request.runId===conflict.id);assert.equal(application.phase,'waiting');const applyCount=posts('/apply').length;
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();await reopen();
@@ -59,16 +79,21 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'headless fixture output');
assert.equal(posts('/apply').length,applyCount);
// Managed image results are readable, and copying shares bytes without a run.
// 图片结果必须可从资源权威端和显示路径读取;复制元素共享 assetId,不复制任务或重新上传字节。
const image=engine.getSnapshot().document.elements.get(imageId),oldAsset=image.data.assetId;
const fourth=await begin({target:{id:image.id,incarnation:image.incarnation},boardId:rootId,modelId:'fixture-image',prompt:'Fixture image',parameters:{},propagate:false});
const fourthRun=byOperation(fourth.operationIds[0]);await control('execute');await poll();
assert.equal(engine.getSnapshot().runtime.runs.get(fourthRun.id).status,'succeeded');const generated=engine.getSnapshot().document.elements.get(imageId);assert.notEqual(generated.data.assetId,oldAsset);
// 分别取得资源元数据、原始字节和显示句柄;后续核对 PNG 签名、SHA-256、尺寸和可显示内容。
const metadata=await assets.authority.resolve(generated.data.assetId,signal),original=await assets.authority.read(generated.data.assetId,signal),display=await engine.displayResource(generated.data.assetId);
// 显示句柄是带引用的资源,即使字节或元数据断言失败也必须 release。
try{const bytes=Buffer.from(await original.arrayBuffer());assert.deepEqual([...bytes.subarray(0,8)],[137,80,78,71,13,10,26,10]);assert.equal(createHash('sha256').update(bytes).digest('hex'),metadata.digest);assert.equal(metadata.asset.width,3);assert.equal(metadata.asset.height,2);assert.ok((await readDisplay(display.url)).length>0);}finally{display.release();}
// 深复制生成后的图片,等待复制作业完成后检查稳定 ID 映射;复用资源不应增加 RunRecord。
const runCount=engine.getSnapshot().runtime.runs.size,copyId=await engine.imports.duplicate([{id:generated.id,incarnation:generated.incarnation}],{viewCanvasId:rootId});await engine.settle();
const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');assert.equal(engine.getSnapshot().document.elements.get(copy.stableIds['element:'+generated.id]).data.assetId,generated.data.assetId);
assert.equal(engine.getSnapshot().runtime.runs.size,runCount);assert.equal(posts('/ai/runs').length,initialStarts+4);assert.equal(posts('/assets').length,initialUploads);assert.equal((await control('stats')).executions,3);
// 只有前面全部断言通过才返回这些标记;它们描述本次远程调用结果,不代表其他入口都已覆盖。
return {runtimeAI:true,aiLostAdmission:true,aiCancelReconciled:true,aiConflictApplyUndo:true,aiManagedImage:true,aiCopyNoRun:true,aiExecutionCount:3};
// 成功或失败都关闭最新引擎实例,释放本次打开的运行时资源。
}finally{await engine.close();}
}
+84 -17
View File
@@ -1,5 +1,7 @@
/** Invoked by Foundry's isolated PostgreSQL/HTTP integration test on its CI runner.
* No mocked model writes: only the recovery store and lost-response fault are local.
/**
* 由 Foundry CI runner 的隔离 PostgreSQL/HTTP 集成环境调用。文档、迁移和资产写入使用真实服务;本地恢复存储及丢响应是测试替身。
* 执行前必须注入隔离服务身份与测试数据;本文件不启动数据库,也不在没有 fixture 时降级为本地服务。
* 依次覆盖迁移、未知回执恢复、分区复制/解散、增量加载期间导入与上传,以及跨项目资源采用;AI 场景由环境开关追加。
*/
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
@@ -12,98 +14,163 @@ import {createFoundryRuns} from '../../src/lib/canvas-service/runs.ts';
import {createCanvasMigration} from '../../src/lib/canvas-migration.ts';
import {verifyFoundryAI} from './foundry-ai.integration.mjs';
import {decodeModelStream} from '../../src/lib/canvas-service/model-stream.ts';
// 从 runner 环境取得隔离服务及认证参数,仅在进程内供请求使用;缺少任一必需参数直接失败。
const {FOUNDRY_HTTP_URL:url,FOUNDRY_TEST_PROJECT:project,FOUNDRY_TEST_ACCOUNT:accountId,FOUNDRY_TEST_WORKSPACE:workspaceId,FOUNDRY_TEST_ROOT:rootId,FOUNDRY_TEST_TOKEN:token}=process.env;
assert.ok(url&&project&&accountId&&workspaceId&&rootId&&token,'requires isolated Foundry HTTP fixture');
// 装配实际模型校验与项目 API 根路径,calls 只记录测试需要的协议信息。
const registry=createRegistry(builtinComponents()),basePath=`${url}/projects/${project}/`,calls=[];
// 各故障开关只在相应成功响应已经返回后生效,不把发送前失败伪装成“服务已提交但响应丢失”。
let loseNextAck=false,loseNextAssetAck=false,loseNextAIAdmission=false,loseNextAIAction=false;
// 只向 fixture 同源地址添加 Bearer 与预期账户头,防止测试资源读取把凭据发送到其他 origin。
const authenticatedFetch=async(resource,init={})=>{assert.equal(new URL(resource).origin,new URL(url).origin);const headers=new Headers(init.headers);headers.set('Authorization',`Bearer ${token}`);headers.set('X-Expected-Account',accountId);return fetch(resource,{...init,headers});};
// 复用生产 HTTP 传输适配器;测试身份固定,身份变化的校验由单元用例另行覆盖。
const rawTransport=createFoundryTransport({basePath,accountId,isCurrentIdentity:()=>true,fetch:authenticatedFetch});
const transport=async input=>{const call={method:input.method,path:input.path,requestId:input.requestId,body:input.body};calls.push(call);const result=await rawTransport(input);if(input.path.includes('/canvas/ai/')||input.path.endsWith('/canvas/model/operations'))call.response={httpStatus:result.status,...Object.fromEntries(['protocol_version','request_id','operation_id','action','admission','status','error'].filter(key=>result.body?.[key]!==undefined).map(key=>[key,result.body[key]]))};if(loseNextAIAdmission&&input.method==='POST'&&input.path.endsWith('/ai/runs')&&result.status===202){loseNextAIAdmission=false;throw Error('injected response loss after AI admission');}if(loseNextAIAction&&input.method==='POST'&&/\/(cancel|apply)$/.test(input.path)&&result.status===200){loseNextAIAction=false;throw Error('injected response loss after AI action');}if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}return result;};
// 记录请求与有限响应摘要,再按动作类型丢弃一次已成功的响应;未命中开关时返回真实结果。
const transport=async input=>{const call={method:input.method,path:input.path,requestId:input.requestId,body:input.body};calls.push(call);const result=await rawTransport(input);if(input.path.includes('/canvas/ai/')||input.path.endsWith('/canvas/model/operations'))call.response={httpStatus:result.status,...Object.fromEntries(['protocol_version','request_id','operation_id','action','admission','status','error'].filter(key=>result.body?.[key]!==undefined).map(key=>[key,result.body[key]]))};
// AI 受理仅在 202 时丢响应,确保服务真正创建过任务;重新开关由后续场景显式控制。
if(loseNextAIAdmission&&input.method==='POST'&&input.path.endsWith('/ai/runs')&&result.status===202){loseNextAIAdmission=false;throw Error('injected response loss after AI admission');}
// 取消或应用只拦截成功 POST;故障发生后本地要通过动作回执恢复,不重跑生成。
if(loseNextAIAction&&input.method==='POST'&&/\/(cancel|apply)$/.test(input.path)&&result.status===200){loseNextAIAction=false;throw Error('injected response loss after AI action');}
// 上传遇到任意 2xx 后才注入丢响应,不能把真实拒绝当作待确认上传。
if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}
// 通用写入故障只拦截一次成功 POST,服务端副作用和回执已经存在。
if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}
// 其余请求完整返回真实传输结果,不修改协议 body 或 stream。
return result;};
// 原项目操作以 runner 准备的 material 为规范项目作用域;跨项目场景稍后另建 root。
const path=`workspaces/${workspaceId}/materials/${rootId}/canvas`;
// fixture 初始必须是待迁移旧画布;这保证随后恢复的是迁移写入而不是现成 canonical 模型。
const migration=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(migration.status,'migration-required');
// 用 Map 模拟迁移本地日志;不在此场景验证浏览器存储或真实跨标签页锁。
const migrationRecords=new Map(),migrationStorage={getItem:key=>migrationRecords.get(key)??null,setItem:(key,value)=>migrationRecords.set(key,value),removeItem:key=>migrationRecords.delete(key)};
// 迁移协调器使用真实 transport 与随机请求 ID,锁替身直接执行 work,专注幂等迁移协议。
const migrationOptions={scope:{accountId,workspaceId},registry,transport,storage:migrationStorage,isCurrentIdentity:()=>true,createId:randomUUID,lock:async work=>work()};
// 真实迁移成功后丢响应,本地仍为 pending;新协调器恢复时禁止再分配 ID,只查询原受理。
loseNextAck=true;const admission=createCanvasMigration(migrationOptions);assert.equal((await admission.start(rootId,async()=>{})).status,'pending');assert.equal(loseNextAck,false);
// 保存原迁移 ID 和 body,并统计 POST 次数;恢复不得重新发送迁移。
const frozenMigration=admission.read(),migrationId=frozenMigration.id,migrationBody=frozenMigration.body,migrationPosts=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/migrate')).length;
assert.equal((await createCanvasMigration({...migrationOptions,createId:()=>assert.fail('recovery allocated a new migration identity')}).resume()).status,'applied');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/migrate')).length,migrationPosts);assert.equal(migrationRecords.size,0);
// 直接查询原迁移回执,再以同 ID/body 重试并比较 identity_map,检验真实服务的幂等迁移结果。
const migrated=await transport({method:'GET',path:path+'/model/operations/'+migrationId});assert.equal(migrated.status,200);assert.equal(migrated.body.status,'applied');
const retried=await transport({method:'POST',path:path+'/model/migrate',body:migrationBody,requestId:migrationId});assert.deepEqual(retried.body.identity_map,migrated.body.identity_map);
// 迁移完成后完整读取规范模型;后续引擎以此作为初始 ACK 基线。
let initial=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(initial.status,'ready');
// 隔离 fixture 必须含 frame 和子 canvas 成员,保证后续有真实嵌套结构可操作。
const sourceFrame=[...initial.document.elements.values()].find(element=>element.componentId==='frame'),sourceMember=[...initial.document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId!==rootId);assert.ok(sourceFrame&&sourceMember);
// 本地恢复镜像、文件仓库和时钟在引擎关闭后保留,views 不参与持久化。
const scope={accountId,workspaceId,projectId:rootId},files=new Map(),errors=[];let image=null,owner,now=0;
// 共享资源仓库替身按 authority 与 assetId 定位;此远程消费场景禁止调用仓库收集。
const sharedRecords=new Map(),depotPort={async read(_leader,authority,id){return sharedRecords.get(JSON.stringify([authority,id]));},async collect(){throw Error('remote consumer does not collect');}};
// commit 同步更新共享资源引用,缺少新字节时继续使用已有字节,供跨项目采用验证。
const share=batch=>{for(const write of batch.depot?.writes??[]){const key=JSON.stringify([write.ref.authority,write.ref.asset.id]);sharedRecords.set(key,{ref:write.ref,bytes:write.bytes??sharedRecords.get(key)?.bytes});}};
const storage={assetDepot:depotPort,async claim(){assert.equal(owner,undefined);const held={scope,fence:1,active:true,async release(){held.active=false;owner=undefined;}};owner=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,owner);const next=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=next;share(batch);return{durableRootRevision:next.rootRevision};},async readFile(_held,id){return files.get(id);},close(){}};
// 项目存储使用独占 owner;commit 克隆 Root,按批次增删本地文件并写共享 depot,readFile 从项目仓库读。
const storage={assetDepot:depotPort,
// 一个 fixture 项目一次只允许一个持有者;release 使旧 owner 失活并允许后续重开。
async claim(){assert.equal(owner,undefined);const held={scope,fence:1,active:true,async release(){held.active=false;owner=undefined;}};owner=held;return held;},
// 恢复镜像克隆后交给引擎,避免复用同一个可变 JSON 对象。
async recover(){return structuredClone(image);},
// 当前 owner 才能提交;文件变化、Root 镜像和 depot 更新在此内存调用内处理,不模拟真实磁盘事务故障。
async commit(held,batch){assert.strictEqual(held,owner);const next=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=next;share(batch);return{durableRootRevision:next.rootRevision};},
// 项目本地文件读取;跨项目共享字节由另一个 assetDepot 端口提供。
async readFile(_held,id){return files.get(id);},close(){}};
// 轮询定时器只登记在 Map,不依赖真实等待。
let nextTimer=0;const timers=new Map();
// 触发当前已到期的定时器,按 due 再按 ID 稳定排序,并返回本轮触发数供 AI 场景有界排空。
const tick=()=>{const ready=[...timers].filter(([,timer])=>timer.due<=now).sort((a,b)=>a[1].due-b[1].due||a[0]-b[0]);let fired=0;for(const[id,timer]of ready)if(timers.delete(id)){fired++;timer.callback();}return fired;};
// 文档、资产和显示读取使用生产适配器;显示 URL 指向同一真实 API,时钟采用可控定时器。
const persistence=createFoundryPersistence({registry,workspaceId,rootId,transport}),assets=createFoundryAssets({workspaceId,project,origin:'https://api.example.com',transport,displayURL:id=>`${basePath}workspaces/${workspaceId}/assets/${encodeURIComponent(id)}?variant=preview`}),clock={now:()=>now,setTimer(callback,delay){const id=++nextTimer;timers.set(id,{callback,due:now+delay});return id;},clearTimer(id){timers.delete(id);}};
// 每次重开从同一 scope、存储及服务端口恢复;extra 可为渐进加载场景替换捕获模型和 loading 状态。
const open=(extra={})=>openProjectEngine({scope,registry,storage,persistence,assets,document:initial.document,versions:initial.versions,clock,createId:randomUUID,onError:error=>errors.push(error),...extra});
// 创建合法文本节点外壳;各场景再替换类型、归属或 data,ID 使用随机 UUID 避免污染既有 fixture 身份。
const node=(id,ownerCanvasId=rootId)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
// 后续 try/finally 持有最新引擎引用,跨场景重开后最终关闭当前实例。
let engine=await open();
try{
// 文档写入真实成功但客户端未知;重开查询原请求,不新增 POST,完整读取再确认远端内容。
loseNextAck=true;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:sourceMember.id,incarnation:1},patch:{text:'committed before lost ACK'}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1);assert.equal(request.phase,'waiting');const sent=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
await engine.close();now=10_000;engine=await open();await engine.settle();const restored=engine.getSnapshot().runtime.ledger.requests.get(request.id);assert.equal(restored.body,request.body);assert.equal(restored.phase,'resolved');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+request.id)));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,sent);
let live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(sourceMember.id).data.text,'committed before lost ACK');
// 同一事务创建父级锚点、两层分区和连线,作为深复制及解散的真实服务样本。
const anchor=node(randomUUID()),frame={...node(randomUUID()),componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Nested source',text:'',presentation:{contentScale:2}}},childId=randomUUID(),member=node(randomUUID(),childId),nested={...node(randomUUID(),childId),componentId:'frame'},grandchildId=randomUUID(),leaf=node(randomUUID(),grandchildId),edge={id:randomUUID(),incarnation:1,ownerCanvasId:rootId,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:anchor.id},target:{elementId:frame.id},path:null};
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:anchor});tx.dispatch({type:'element.create',element:frame});tx.dispatch({type:'canvas.create',canvas:{id:childId,parent:{canvasId:rootId,entryElementId:frame.id}}});tx.dispatch({type:'element.create',element:member});tx.dispatch({type:'element.create',element:nested});tx.dispatch({type:'canvas.create',canvas:{id:grandchildId,parent:{canvasId:childId,entryElementId:nested.id}}});tx.dispatch({type:'element.create',element:leaf});tx.dispatch({type:'element.create',element:edge});}).status,'applied');await engine.settle();
const creation=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1);assert.equal(creation.phase,'resolved','nested partition creation was not confirmed: '+JSON.stringify({phase:creation.phase,outcome:creation.outcome,error:creation.error,response:calls.findLast(call=>call.requestId===creation.id)?.response}));
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(member.id).ownerCanvasId,childId);assert.ok(live.document.canvases.has(childId));
// 同时选父 frame 与其成员以检查深复制去重;复制后上级锚点应新增一条指向新分区的连线,redo 用新 incarnation。
const copiedId=await engine.imports.duplicate([{id:frame.id,incarnation:1},{id:member.id,incarnation:1}],{viewCanvasId:rootId});await engine.settle();const copied=engine.imports.getJobs().get(copiedId);assert.equal(copied.status,'completed');const copiedFrame=copied.stableIds['element:'+frame.id],copiedMember=copied.stableIds['element:'+member.id];assert.ok(copiedFrame&&copiedMember);
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.ok(live.document.elements.has(copiedMember));assert.ok([...live.document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId===anchor.id&&element.target.elementId===copiedFrame));assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(copiedFrame).incarnation,2);
// Dissolve is an owning-tree edit, not recursive deletion. Lose the successful
// real DB response, reopen/query, then restore and repeat the same history unit.
// 解散修改 owning tree,不递归删除入口或后代;真实 DB 成功后丢 ACK,再查询恢复并重做同一历史单元。
loseNextAck=true;assert.equal(engine.editor.dispatch({type:'canvas.dissolve',canvasId:childId,entry:{id:frame.id,incarnation:1}}).status,'applied');await engine.settle();
// 解散已经在数据库完成但本地 waiting;记录请求次数和冻结体,再重开查询,不能发送第二次解散。
const dissolveRequest=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1),dissolveWrites=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;assert.equal(dissolveRequest.phase,'waiting');assert.equal(loseNextAck,false,'dissolve did not receive a successful real service response');
await engine.close();now+=10_000;engine=await open();await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(dissolveRequest.id).phase,'resolved');assert.equal(engine.getSnapshot().runtime.ledger.requests.get(dissolveRequest.id).body,dissolveRequest.body);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,dissolveWrites);
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.canvases.has(childId),false);assert.equal(live.document.elements.get(member.id).ownerCanvasId,rootId);assert.equal(live.document.elements.get(nested.id).ownerCanvasId,rootId);assert.equal(live.document.canvases.get(grandchildId).parent.canvasId,rootId);assert.equal(live.document.elements.get(leaf.id).ownerCanvasId,grandchildId);assert.deepEqual(live.document.elements.get(leaf.id).geometry,leaf.geometry);assert.equal(live.document.elements.get(frame.id).incarnation,1);assert.deepEqual(live.document.elements.get(edge.id).target,{elementId:frame.id});
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(member.id).ownerCanvasId,childId);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.canvases.has(childId),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(member.id).ownerCanvasId,childId);assert.equal(live.document.canvases.get(childId).parent.entryElementId,frame.id);assert.equal(live.document.canvases.get(grandchildId).parent.canvasId,childId);assert.equal(live.document.elements.get(leaf.id).ownerCanvasId,grandchildId);
// 逐项导入先接受一个合法元素再遇未知 kind,要求 partial 报告且保留已提交来源 key。
const importId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'native',record:node('native','$target')})+'\n'+JSON.stringify({kind:'unsupported'})+'\n']),rootId);await engine.settle();const imported=engine.imports.getJobs().get(importId);assert.equal(imported.status,'partial');assert.equal(imported.committedSourceKeys.length,1);
// Put a deterministic sorted tail in a different owner. Every original ID is
// lower than these records, so the first real 64-record frame certifies the
// root/new child while the original child is provably still incomplete.
// 尾部 ID 严格大于全部原 ID,且属于另一 owner;真实 64 项首帧可认证根/新子分区,但原有子分区仍缺成员。
// 收集现有元素、canvas 与墓碑 ID,计算严格更大的 70 个尾部 ID,使流排序和缺失归属可预测。
const namespace=[...engine.getSnapshot().document.elements.keys(),...engine.getSnapshot().document.canvases.keys(),...engine.getSnapshot().document.tombstones.keys()];
const maximum=namespace.reduce((max,id)=>{const value=BigInt('0x'+id.replaceAll('-',''));return value>max?value:max;},0n);assert.ok(maximum+70n<(1n<<128n));
const tail=Array.from({length:70},(_,i)=>{const hex=(maximum+BigInt(i+1)).toString(16).padStart(32,'0');return node(`${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`,sourceMember.ownerCanvasId);});
assert.equal(engine.editor.transaction(tx=>{for(const element of tail)tx.dispatch({type:'element.create',element});}).status,'applied');await engine.settle();
// Consume the real negotiated stream through the production decoder, pausing
// after the first nonempty frame. HTTP/DB ACKs may advance before its old tail.
// 消费生产解码器的真实协商流,并暂停在首个非空帧;旧捕获尾部到达前,HTTP/DB ACK 可继续推进。
await engine.close();image=null;initial=await loadFoundryModel(transport,workspaceId,rootId,registry);
// 协商真实 model-stream-v1 并交给生产解码器;preview 提取前缀而不把它冒充完整基线。
const streamed=await transport({method:'GET',path:path+'/model',responseFormat:'model-stream-v1'});assert.ok(streamed.stream);const controller=new AbortController(),preview=createModelPreview(registry);let capture,sourceToken,sourceVersions;
const stream=decodeModelStream(streamed.stream,rootId,registry,controller.signal);
// 读取到第一个非空 receiving 帧即暂停,保留同一迭代器和来源 token/版本供稍后继续消费。
while(true){const next=await stream.next();assert.equal(next.done,false);const chunk=next.value;
const value=preview.update(chunk.value);if(chunk.value.status==='receiving'){capture=value;sourceToken=chunk.snapshotToken;sourceVersions=chunk.versions;if(chunk.value.receivedElements>0)break;}
}
assert.ok(capture.sourceCanvases&&capture.elementOwners);assert.ok(capture.document.elements.size<initial.document.elements.size);assert.equal(capture.document.elements.has(tail.at(-1).id),false);
// v6 loading 描述包含完整来源树、元素 owner 和成员计数;允许已认证完整 owner 内作业,而未载入尾部仍保持缺失。
const loading={version:6,sourceToken,sourceCanvases:capture.sourceCanvases,elementOwners:capture.elementOwners,canvasElementCounts:capture.canvasElementCounts,declaredCanvasIds:capture.sourceCanvases.map(canvas=>canvas.id),receivedElements:capture.receivedElements,totalElements:capture.totalElements};
engine=await open({document:capture.document,versions:sourceVersions,loading});assert.equal(engine.editor.getQueries().membersResult(sourceMember.ownerCanvasId).complete,false);assert.equal(engine.imports.canStart(childId).enabled,true);const partialId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'partial-native',record:node('partial-native','$target')})+'\n']),childId);await engine.settle();assert.equal(engine.imports.getJobs().get(partialId).status,'completed');assert.equal(engine.loading.getState().version,6);
// 旧流尾尚未到达时深复制已具备闭包的分区;不能为了复制强行等待整个项目,也不能把缺失尾部当作已加载。
const partialCopyId=await engine.imports.duplicate([{id:frame.id,incarnation:1}],{viewCanvasId:rootId});await engine.settle();const partialCopy=engine.imports.getJobs().get(partialCopyId);assert.equal(partialCopy.status,'completed');assert.equal(engine.getSnapshot().document.elements.has(tail.at(-1).id),false);const partialFrame=partialCopy.stableIds['element:'+frame.id];assert.ok(partialFrame);
// The upload commits on the real server, then its response is deliberately
// lost. Reopening must query that upload before either image document sends.
// 上传真实提交后丢响应;恢复必须先查询原上传,两个图片文档不能绕过该依赖或重复上传。
loseNextAssetAck=true;const imageId=randomUUID();let imageResource;
// 将真实 PNG 暂存为资源并立即创建图片;上传已经成功后丢响应,图片文档须等待同一上传回执协调。
const imageResult=engine.stageFiles([{name:'live.png',bytes:new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAIAAAASFvFNAAAAEElEQVR4nGP4z8AAQQxwFgBB0gX7h/C5SAAAAABJRU5ErkJggg==','base64')],{type:'image/png'})}],([ref],tx)=>{imageResource=ref;tx.dispatch({type:'element.create',element:{...node(imageId,childId),componentId:'image',data:{title:'Live scoped image',text:'',assetId:ref.id,src:ref.src}}});});assert.equal(imageResult.status,'applied');assert.equal(engine.getSnapshot().document.elements.has(imageId),true);await engine.settle();const upload=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='asset.upload');assert.ok(upload);assert.equal(loseNextAssetAck,false,'successful upload response was not intercepted');assert.equal(upload.phase,'waiting');const uploadCount=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length;
// 复制尚未确认上传的图片仍引用同一资源,重开查询原上传而非再次传字节。
const imageCopyId=await engine.imports.duplicate([{id:imageId,incarnation:1}],{viewCanvasId:childId});await engine.settle();const imageCopy=engine.imports.getJobs().get(imageCopyId);assert.equal(imageCopy.status,'completed');const copiedImage=imageCopy.stableIds['element:'+imageId];assert.equal(engine.getSnapshot().document.elements.get(copiedImage).data.assetId,imageResource.id);assert.equal(engine.getSnapshot().document.elements.has(tail.at(-1).id),false);
await engine.close();now+=10_000;engine=await open({document:capture.document,versions:sourceVersions,loading});await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(upload.id).phase,'resolved');assert.equal(engine.getSnapshot().runtime.ledger.requests.get(upload.id).body,upload.body);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/assets/operations/'+upload.id)));
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(imageId).data.assetId,imageResource.id);assert.equal(live.document.elements.get(copiedImage).data.assetId,imageResource.id);
// 继续消费原迭代器直到 ready 和 EOF,再用原 token/版本完成 loading;旧尾部合并不能覆盖期间新增内容。
let terminal;for await(const chunk of stream){preview.update(chunk.value);if(chunk.value.status==='ready')terminal=chunk.value;}assert.ok(terminal,'real source never reached terminal and EOF');
assert.equal((await engine.loading.complete(terminal.document,sourceVersions,{sourceToken})).status,'applied');await engine.settle();live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.ok(live.document.elements.has(engine.imports.getJobs().get(partialId).stableIds['element:partial-native']));assert.ok(live.document.elements.has(partialFrame));assert.ok(live.document.elements.has(tail.at(-1).id));assert.ok([...live.document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId===anchor.id&&element.target.elementId===partialFrame));
// 独立读取资源授权元数据及原字节,和本地资源记录、暂存文件比较,不仅检查图片元素的 assetId。
const authorized=await assets.authority.resolve(imageResource.id,new AbortController().signal),originalBytes=await assets.authority.read(imageResource.id,new AbortController().signal),record=engine.getSnapshot().runtime.resources.get(imageResource.id);
assert.equal(authorized.digest,record.digest);assert.deepEqual(authorized.asset,record.stored);assert.equal(originalBytes.size,record.byteLength);assert.deepEqual(Buffer.from(await originalBytes.arrayBuffer()),Buffer.from(await files.get(imageResource.id).arrayBuffer()));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
// A second canonical root in the same workspace adopts the first root's
// authorized identity. Shared bytes remain local; the real server validates
// the new document reference independently, with no second upload.
// 同 workspace 的第二个 canonical root 采用已有授权资源;共享字节保留,真实服务独立验证新引用,无需第二次上传。
await engine.close();
// 在同一隔离 workspace 内建立第二个 material 并迁移为规范项目,供资源跨项目采用场景使用。
const targetCreated=await transport({method:'POST',path:`workspaces/${workspaceId}/materials`,body:JSON.stringify({kind:'board',title:'Cross-project depot target',content:{}})});assert.equal(targetCreated.status,201);const targetRoot=targetCreated.body.id;
const targetMigration=await loadFoundryModel(transport,workspaceId,targetRoot,registry);assert.equal(targetMigration.status,'migration-required');const targetMigrationId=randomUUID();assert.equal((await transport({method:'POST',path:`workspaces/${workspaceId}/materials/${targetRoot}/canvas/model/migrate`,requestId:targetMigrationId,body:JSON.stringify({protocol_version:2,request_id:targetMigrationId,expected_versions:Object.fromEntries(targetMigration.sourceVersions)})})).status,200);
const targetInitial=await loadFoundryModel(transport,workspaceId,targetRoot,registry);assert.equal(targetInitial.status,'ready');let targetImage=null,targetOwner,adoptionOnly=true;const targetFiles=new Map();
const targetStorage={...storage,async claim(targetScope){assert.equal(targetOwner,undefined);const held={scope:targetScope,fence:1,active:true,async release(){held.active=false;targetOwner=undefined;}};targetOwner=held;return held;},async recover(){return structuredClone(targetImage);},async commit(held,batch){assert.strictEqual(held,targetOwner);if(adoptionOnly)assert.equal((batch.files??[]).length,0,'adoption duplicated project-local bytes');for(const file of batch.files??[]){assert.equal(file.bytes.type.startsWith('image/'),false,'managed image copy duplicated bitmap bytes');targetFiles.set(file.id,file.bytes);}for(const id of batch.deleteFiles??[])targetFiles.delete(id);targetImage=structuredClone(batch.image);share(batch);return{durableRootRevision:targetImage.rootRevision};},async readFile(_held,id){return targetFiles.get(id);}};
// 第二项目有独立 owner、镜像和文件表,但共享 depot;采用阶段不允许复制文件,后续也不允许复制托管图片字节。
const targetStorage={...storage,
// 第二项目有独立占用,不能借用第一个项目的 owner。
async claim(targetScope){assert.equal(targetOwner,undefined);const held={scope:targetScope,fence:1,active:true,async release(){held.active=false;targetOwner=undefined;}};targetOwner=held;return held;},
// 重开读取第二项目自己的镜像,不覆盖第一个项目的持久化记录。
async recover(){return structuredClone(targetImage);},
// 采用阶段禁止任何项目文件写入;AI 扩展后允许非图片文件,但图片仍必须复用托管资源。
async commit(held,batch){assert.strictEqual(held,targetOwner);if(adoptionOnly)assert.equal((batch.files??[]).length,0,'adoption duplicated project-local bytes');for(const file of batch.files??[]){assert.equal(file.bytes.type.startsWith('image/'),false,'managed image copy duplicated bitmap bytes');targetFiles.set(file.id,file.bytes);}for(const id of batch.deleteFiles??[])targetFiles.delete(id);targetImage=structuredClone(batch.image);share(batch);return{durableRootRevision:targetImage.rootRevision};},
// 这里只读第二项目文件表,已有图片原字节必须由共享 depot 解析。
async readFile(_held,id){return targetFiles.get(id);}};
// 第二引擎的文档与 persistence 改绑新 root,资产权威端口及共享仓库仍属于同一 workspace。
const targetOptions={scope:{accountId,workspaceId,projectId:targetRoot},registry,storage:targetStorage,assets,persistence:createFoundryPersistence({registry,workspaceId,rootId:targetRoot,transport}),document:targetInitial.document,versions:targetInitial.versions,clock,createId:randomUUID,onError:error=>errors.push(error)};
// 采用已有授权资源后真实服务验证新项目引用;重开从共享字节显示,undo/redo 只改变元素生命期,不再次上传。
engine=await openProjectEngine(targetOptions);const adoptedId=randomUUID();assert.equal((await engine.adoptResource(imageResource.id,(handle,tx)=>tx.dispatch({type:'element.create',element:{...node(adoptedId,targetRoot),componentId:'image',data:{title:'Adopted existing image',text:'',assetId:handle.id,src:handle.src}}}))).status,'applied');await engine.settle();assert.equal(targetImage.formatVersion,13);assert.equal((await loadFoundryModel(transport,workspaceId,targetRoot,registry)).document.elements.get(adoptedId).data.assetId,imageResource.id);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
await engine.close();engine=await openProjectEngine(targetOptions);const display=await engine.displayResource(imageResource.id);assert.ok(display.url.startsWith('blob:'));assert.deepEqual(Buffer.from(await(await fetch(display.url)).arrayBuffer()),Buffer.from(await originalBytes.arrayBuffer()));display.release();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal((await loadFoundryModel(transport,workspaceId,targetRoot,registry)).document.elements.get(adoptedId).incarnation,2);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
// 仅 FOUNDRY_TEST_AI_WORKER=1 时运行 AI 扩展;随后允许测试作业写入非图片源文件,但仍检查不重复存图片字节。
let aiReport={};if(process.env.FOUNDRY_TEST_AI_WORKER==='1'){
await engine.close();adoptionOnly=false;aiReport=await verifyFoundryAI({open:()=>openProjectEngine({...targetOptions,runs:createFoundryRuns({workspaceId,rootId:targetRoot,registry,transport})}),transport,rootId:targetRoot,imageId:adoptedId,assets,calls,tick,readDisplay:async url=>{const response=await authenticatedFetch(url);assert.equal(response.status,200);return Buffer.from(await response.arrayBuffer());},advance:()=>{now+=10_000;},loseAdmission:()=>{loseNextAIAdmission=true;},loseAction:()=>{loseNextAIAction=true;}});
}
// 容许的错误只有显式注入的丢响应;其他异步错误不能被一并吞掉。
assert.ok(errors.every(error=>String(error).includes('injected response loss')),'unexpected engine error: '+errors.map(String).join(','));
// 全部断言通过才输出本次集成结果标记;AI 标记只有开关执行该分支时才加入。
console.log(JSON.stringify({status:'ok',...aiReport,migrationCoordinator:true,migrationLostAckRecovered:true,migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,dissolveCanvas:true,dissolveLostAckRecovered:true,dissolveUndoRedo:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,authorizedAssetMetadata:true,originalAssetBytes:true,crossProjectAdoption:true,adoptionReopenedBytes:true,requests:calls.length}));
}finally{await engine.close();}
+12
View File
@@ -1,26 +1,38 @@
/**
* 呈现帧队列与外部store适配的调度测试。用可手动推进或故意不执行cancel的时钟,验证合并、立即回显、迟到回调及重入隔离。
* 延迟的是通知呈现,不是editor提交或getSnapshot中的最新事实;这里不运行真实requestAnimationFrame。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {createFrameQueue,createFrameSource} from '../../src/lib/canvas-bindings/frame-queue.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;}};}
// 重复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);});
// 假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);});
// 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);});
// 三次编辑已提交且产生三条历史,画面通知合成一次;getSnapshot在帧执行前也返回最新editor根。
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 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');
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',()=>{
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);
});
// 上游subscribe先通知再抛错要清掉任务;正常通知回调重入触发状态2,必须等下一帧而非混入状态1当前批。
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);
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',()=>{
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();
});
+9
View File
@@ -1,11 +1,18 @@
/**
* bindings几何投影缓存测试。用缩放子图与跨层曲线比较缓存投影和直接投影,按计算次数验证局部失效。
* 统计用于确定性工作量检查,不是浏览器帧率基准;缓存不能把旧查询、旧生命周期或可变预览当成不变输入。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
import {createGeometryProjectionCache} from '../../src/lib/canvas-bindings/geometry-cache.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});
// 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')]);
// 每次draw与直接投影深比较;标题、选择与元数据不增计算,A局部变化只重算自身,F移动再影响后代及路径。
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;};
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);
});
// 折叠清不可见缓存,旧query拒绝;预览进入/退出各重算,删除恢复incarnation=2后重新出现,不能沿用旧记录身份。
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/);
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',()=>{
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);
});
+17
View File
@@ -1,20 +1,37 @@
/**
* 手势坐标上下文与目标可见性的回归。三层canvas加独立子视图,验证祖先位置/缩放/身份变化何时取消预览。
* 同时检查owner链读取缓存与未知祖先数据隔离;这里没有DOM指针捕获,测试的是core会话及placement读取器。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createGesturePlacementReader} from '../../src/lib/canvas-core/gesture-placement.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}});
/** 子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}}]};
/** 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};}
// 根视图预览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);});
// 折叠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');});
// 对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);});
// 分别改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);});
// 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);});
// 祖先生命周期、未来版本或同几何换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);});
// 目标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');});
// 删除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');});
// 用禁止遍历的表包络读取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);});
// 未来版本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);});
@@ -1,3 +1,7 @@
/**
* 历史栈变更证据的回放与恢复测试。验证每个transition可正反应用、只存增量范围,并在本地恢复/冲突方案中保留可验证顺序。
* 内存storage及固定拒绝的persistence制造可重复的冲突环境,不代表真实磁盘崩溃或服务端集成验证。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createEditor,createDocument,createRegistry,builtinComponents,applyHistoryTransition,validateHistoryTransition,historyTransitionChanges,describeHistoryTransition,readMap} from '../../src/lib/canvas-core/index.ts';
@@ -6,21 +10,28 @@ import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
import {replayHistoryEvidence,sameHistory,validateHistoryReplay} from '../../src/lib/canvas-runtime/history-replay.ts';
import {resourcePins} from '../../src/lib/canvas-runtime/resources.ts';
// 共用只读registry和原始文档,每个editor/engine另建状态,避免测试相互继承历史。
const registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'root',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument('project',[{id:'root',parent:null}],[node]);
/** 历史转成可深比较的栈与排序entries数组,忽略Map迭代次序差异,保留条目实际内容。 */
const value=h=>({undo:h.undo,redo:h.redo,entries:[...h.entries].sort(([a],[b])=>a.localeCompare(b))});
/** 对同一第一生命周期节点改正文,settings控制分组或不记历史。 */
const edit=(editor,text,settings)=>editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text}},settings);
// 同组输入、undo/redo、切分支及容量淘汰逐项记证据,再按反序退空、正序恢复最终历史。
test('history transition evidence exactly rewinds and replays typing, undo/redo, redo invalidation and capacity eviction',()=>{
const editor=createEditor({registry,document,historyLimit:2}),events=[];
/** 每次动作捕获前后历史,检查transition版本/修订/entry身份,并双向重放;events随后用于整段回卷。 */
function run(action){const before=editor.getSnapshot(),result=action();assert.equal(result.status,'applied');const after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]),transition=intent.historyTransition;assert.equal(transition.version,1);assert.equal(transition.rootRevision,after.rootRevision);assert.equal(transition.entryId,result.historyEntryId);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));events.push(transition);}
run(()=>edit(editor,'one',{groupId:'typing'}));run(()=>edit(editor,'two',{groupId:'typing'}));run(()=>editor.history.undo());run(()=>editor.history.redo());run(()=>editor.history.undo());run(()=>edit(editor,'branch'));run(()=>edit(editor,'third'));run(()=>edit(editor,'fourth'));
let history=editor.getSnapshot().history;for(const event of [...events].reverse())history=applyHistoryTransition(history,event,'reverse');assert.equal(history.entries.size,0);for(const event of events)history=applyHistoryTransition(history,event,'forward');assert.deepEqual(value(history),value(editor.getSnapshot().history));
});
// 同组80次编辑后检查最后transition偏移79且只追加一项,防止每次都复制全部累计历史。
test('typing transitions append only new ranges rather than embedding the accumulated entry',()=>{
const editor=createEditor({registry,document});for(let i=0;i<80;i++)edit(editor,'text '+i,{groupId:'typing'});const intent=[...editor.getSnapshot().operations.values()].at(-1),patch=intent.historyTransition.entries[0];assert.equal(patch.changes.offset,79);assert.equal(patch.changes.before.length,0);assert.equal(patch.changes.after.length,1);assert.equal(patch.operationIds.after.length,1);assert.equal(historyTransitionChanges(intent.historyTransition).length,1);
});
// 篡改栈/条目内容制造回放冲突,非法偏移或条目ID也拒绝;未来解释版本走明确不可解释错误。
test('transition replay is immutable and rejects changed stack or changed replaced range',()=>{
const editor=createEditor({registry,document}),result=edit(editor,'one'),snapshot=editor.getSnapshot(),transition=snapshot.operations.get(result.operationIds[0]).historyTransition,entry=snapshot.history.entries.values().next().value;
assert.throws(()=>applyHistoryTransition({...snapshot.history,undo:[]},transition,'reverse'),/history_transition_conflict/);
@@ -28,15 +39,19 @@ test('transition replay is immutable and rejects changed stack or changed replac
const invalid=structuredClone(transition);invalid.entries[0].changes.offset=999;assert.throws(()=>validateHistoryTransition(invalid),/invalid_history_transition/);const reserved=structuredClone(transition);reserved.entries[0].after.id='foreign';assert.throws(()=>validateHistoryTransition(reserved),/invalid_history_transition/);assert.throws(()=>applyHistoryTransition(snapshot.history,{...transition,version:2},'reverse'),/history_transition_interpreter_unavailable/);
});
// 传输准备层伪造空entries不得通过,editor根保持原对象。
test('prepareIntent cannot substitute forged local history evidence',()=>{
const editor=createEditor({registry,document,prepareIntent:intent=>({...intent,historyTransition:{...intent.historyTransition,entries:[]}})}),before=editor.getSnapshot();assert.equal(edit(editor,'forged').code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before);
});
/** 内存镜像clone隔离,提交只核对leader;服务端固定not-applied/version_conflict,read返回原始基准。
* 返回image读取器用于破坏副本测试decodeRecovery,调用方finally关闭engine。 */
async function fixture(){
let image,leader;const storage={async claim(scope){return leader={scope,active:true,fence:1,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},close(){}};
const persistence={compile(intent){return{payload:{changes:intent.changes},canvasIds:intent.canvasIds,expectedVersions:{root:1}};},async submit(){return{outcome:'not-applied',error:'version_conflict'};},async query(){throw Error('unexpected');},async read(){return{document,versions:new Map([['root',1]])};}};
const engine=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document,versions:new Map([['root',1]]),storage,persistence});return{engine,image:()=>structuredClone(image)};
}
// 从恢复镜像反推分支前历史;破坏操作证据或修订被判损坏,未来版本需两份同步保留且使资源扫描不完整。
test('durable transition metadata survives orphaned redo entries, validates both copies and keeps future versions opaque',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first');await engine.settle();engine.editor.history.undo();await engine.settle();const before=engine.getSnapshot().history;edit(engine.editor,'branch');await engine.settle();const recovered=decodeRecovery(f.image(),registry),last=[...recovered.operations.values()].at(-1);assert.deepEqual(value(applyHistoryTransition(recovered.history,last.historyTransition,'reverse')),value(before));
const bad=f.image(),id=last.id;bad.payload.operations.find(([key])=>key===id)[1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
@@ -45,17 +60,20 @@ test('durable transition metadata survives orphaned redo entries, validates both
}finally{await engine.close();}
});
// 用不会联网的transport只测试compile,服务操作仅有changes/id,不发送本地historyTransition。
test('local transition evidence does not enter the protocol 2 service payload',()=>{
const root='10000000-0000-4000-8000-000000000001',project=createDocument(root,[{id:root,parent:null}],[{...node,ownerCanvasId:root}]),editor=createEditor({registry,document:project}),result=edit(editor,'new'),intent=editor.getSnapshot().operations.get(result.operationIds[0]);const port=createFoundryPersistence({workspaceId:'10000000-0000-4000-8000-000000000002',rootId:root,registry,transport:async()=>{throw Error('unexpected network');}}),request=port.compile(intent,'10000000-0000-4000-8000-000000000003',{...editor.getSnapshot(),runtime:{versions:new Map([[root,1]])}});assert.deepEqual(Object.keys(request.payload.operations[0]).sort(),['changes','id']);assert.equal(JSON.stringify(request.payload).includes('historyTransition'),false);
});
// 图片已从文档和现有history移除,早期undo未解决时仍由后续transition pin资源;全部确认后才解除。
test('cleared redo resource pins survive a later confirmed branch while an earlier text undo is unresolved',()=>{
const editor=createEditor({registry,document});edit(editor,'edited');editor.dispatch({type:'element.create',element:{...node,id:'image',componentId:'image',data:{title:'Image',text:'',src:'https://assets.example/image',assetId:'asset'}}});editor.history.undo();const pending=editor.history.undo();edit(editor,'branch');const snapshot=editor.getSnapshot(),last=[...snapshot.operations.values()].at(-1),operations=new Map([...snapshot.operations].map(([id,intent])=>[id,{intent,phase:id===pending.operationIds[0]?'pending':'resolved',blockers:[],basePresence:{}}]));
assert.equal(snapshot.document.elements.has('image'),false);assert.equal([...snapshot.history.entries.values()].some(entry=>entry.changes.some(change=>change.kind==='element'&&change.id==='image')),false);
const pins=resourcePins(snapshot,{operations},registry,new Map());assert.deepEqual([...pins.pins.get('asset')],[`history-transition:${last.id}`]);operations.set(pending.operationIds[0],{...operations.get(pending.operationIds[0]),phase:'resolved'});assert.equal(resourcePins(snapshot,{operations},registry,new Map()).pins.has('asset'),false);
});
// keep-local方案带采用证据,新replacement操作不继承旧transition;保存后解码保留证据,篡改范围被拒绝。
test('conflict recovery records one adoption transition and never copies stale evidence to replacement requests',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'pending');await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));assert.ok(plan.audit.historyTransition);assert.deepEqual(value(applyHistoryTransition(plan.history,plan.audit.historyTransition,'reverse')),value(before.history));assert.equal(plan.operations.get(plan.operationIds[0]).historyTransition,undefined);await session.resolve(plan);await engine.settle();const recovered=decodeRecovery(f.image(),registry);assert.deepEqual(recovered.runtime.recoveries.get(plan.id).historyTransition,plan.audit.historyTransition);
const bad=f.image();bad.payload.runtime.recoveries[0][1].historyTransition.entries[0].changes.offset=999;assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/);
@@ -63,18 +81,21 @@ test('conflict recovery records one adoption transition and never copies stale e
});
// 改label时保留未来嵌套元数据,反向应用仍精确恢复before。
test('transition patches preserve opaque history-entry metadata alongside changed fields',()=>{
const editor=createEditor({registry,document});edit(editor,'one');const history=editor.getSnapshot().history,id=history.undo[0],original={...history.entries.get(id),future:{nested:{keep:true}}},before={...history,entries:new Map([[id,original]])},after={...history,entries:new Map([[id,{...original,label:'New label'}]])},transition=describeHistoryTransition(before,after,10);
const result=applyHistoryTransition(before,transition,'forward');assert.deepEqual(result.entries.get(id).future,{nested:{keep:true}});assert.deepEqual(value(applyHistoryTransition(result,transition,'reverse')),value(before));
});
// 创建、改名、删除再恢复导致incarnation=2,证据须覆盖多个旧条目并能双向还原映射。
test('restoration evidence reverses incarnation remapping across earlier history entries',()=>{
const editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:{...node,id:'new'}});editor.dispatch({type:'element.data',target:{id:'new',incarnation:1},patch:{title:'edited'}});editor.dispatch({type:'element.remove',target:{id:'new',incarnation:1}});const before=editor.getSnapshot(),result=editor.history.undo(),after=editor.getSnapshot(),transition=after.operations.get(result.operationIds[0]).historyTransition;
assert.equal(after.document.elements.get('new').incarnation,2);assert.ok(transition.entries.length>=3);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history));
});
// 分别测几何与同组文本:采用锚点+replacement序列重放应等于plan.history;缺事件/缺操作拒绝,未来版本保留opaque。
test('adoption evidence composes the anchor and replacement events into the exact planned history and survives decoding',async()=>{
for(const geometry of [false,true]){
const f=await fixture(),engine=f.engine;try{if(geometry)engine.editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});else edit(engine.editor,'first',{groupId:'typing'});await engine.settle();if(!geometry)edit(engine.editor,'second',{groupId:'typing'});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),selected=new Map([...before.runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local'])),plan=session.plan(selected),proof=plan.audit.historyReplay;assert.ok(proof);validateHistoryReplay(proof,before.rootRevision+1);assert.ok(sameHistory(replayHistoryEvidence(before.history,proof,id=>plan.operations.has(id)),plan.history));assert.deepEqual(proof.events.filter(event=>event.kind==='operation').map(event=>event.operationId),plan.operationIds);await session.resolve(plan);await engine.settle();const image=f.image(),decoded=decodeRecovery(image,registry);assert.deepEqual(decoded.runtime.recoveries.get(plan.id).historyReplay,proof);
@@ -85,20 +106,24 @@ test('adoption evidence composes the anchor and replacement events into the exac
}
});
// 几何不是可选data字段,undo/redo不得额外引入presence标记;证据JSON往返及正向回放仍一致。
test('geometry undo and redo keep optional presence markers absent and history transitions serializable',()=>{
const editor=createEditor({registry,document});editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});for(const direction of ['undo','redo']){const before=editor.getSnapshot(),result=editor.history[direction](),after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]);assert.equal(Object.hasOwn(intent.changes[0],'beforePresent'),false);assert.equal(Object.hasOwn(intent.changes[0],'afterPresent'),false);assert.deepEqual(JSON.parse(JSON.stringify(intent.historyTransition)),intent.historyTransition);assert.ok(sameHistory(applyHistoryTransition(before.history,intent.historyTransition,'forward'),after.history));}
});
// history=false让最终栈为空,仍必须有replacement事件;删掉证据后恢复解码拒绝,不能只凭栈相等接受。
test('history-disabled replacement evidence cannot be omitted merely because the final stacks are unchanged',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'untracked',{history:false});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.history.entries.length,0);assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,1);image.payload.runtime.recoveries[0][1].historyReplay.events=[];assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
});
// 两次不记历史操作的证据仍有顺序,反转events必须判损坏。
test('adoption evidence preserves replacement sequence even when all history transitions are no-ops',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first',{history:false});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}},{history:false});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map([...engine.getSnapshot().runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,2);image.payload.runtime.recoveries[0][1].historyReplay.events.reverse();assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
});
// 内部不可变transition复用冻结范围数组,但调用方validator仍逐条执行并可拒绝当前schema。
test('resource scans reuse validated immutable transition ranges without skipping caller validation',()=>{
const editor=createEditor({registry,document});edit(editor,'cached');
const transition=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
@@ -108,6 +133,7 @@ test('resource scans reuse validated immutable transition ranges without skippin
assert.throws(()=>validateHistoryTransition(transition,()=>{throw Error('current schema rejected');}),/current schema rejected/);
});
// 改变可变/浅冻结/getter输入后扫描须读新值,非法偏移须拒绝,不能套用内部证明缓存。
test('mutable, shallow frozen and accessor transition evidence remains live across resource scans',()=>{
const editor=createEditor({registry,document});edit(editor,'source');
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
@@ -123,6 +149,7 @@ test('mutable, shallow frozen and accessor transition evidence remains live acro
assert.equal(historyTransitionChanges(accessor)[0].after,'getter changed');
});
// 非枚举隐藏循环和稀疏数组不能递归死循环,也不能取得可复用的范围缓存资格。
test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{
const editor=createEditor({registry,document});edit(editor,'source');
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
+18
View File
@@ -1,3 +1,7 @@
/**
* 模型与投影命中的几何/预算回归。覆盖自定义形状、屏幕像素容差、嵌套坐标、预览、曲线细分和实际锚点适配。
* complete、confirmed、possible及diagnostics分别检查;未知或预算耗尽不能伪造确定命中。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
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 {compatConnector,measuredConnector} from '../../src/lib/canvas-bindings/connectors.ts';
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}});
/** 连线工厂允许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});
/** 单canvas P的最小文档,嵌套用例单独构造树。 */
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);
/** 按该投影文档建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);
// queryBudget在editor创建时捕获冻结,外部后改不影响;编辑/undo沿用配置,非法预算和中途换预算拒绝。
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;
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/);
});
// 自定义圆形可命中逻辑矩形外,radius变化失效而title不失效;registry捕获依赖,非法协议/响应给明确拒绝或不完整结果。
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']);
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']});
});
// 旋转缩放父子树在0.1/1/4倍屏幕坐标往返只转换一次;displayPath反映所开视图根,像素容差反算随zoom变化。
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});
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);}
});
// 预览A移到x=5000,显式previewIds补入候选并排除旧位置;registry/文档来源不匹配和退化zoom均拒绝。
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);
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/);
});
/** 按三次贝塞尔多项式正向生成曲线点,作为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});
// 正向曲线点应confirmed,弦中点不误命中;一步预算只possible且budget=true,极大坐标不能产NaN距离。
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 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));
});
// 按显示曲线跨zoom命中,受限细分为possible/不完整;折叠重定向显示端点到F但文档端点仍A。
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);
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');
});
// Handle在外侧边缘求锚点,Loose目标可回退source;不存在的handle或node不捏造边界点。
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);
});
// 自定义圆节点与粗线使用模型协议及服务锚点;缺锚点/非法线宽/handler抛错分别报告诊断,opaque path保持原样。
test('registered custom renderers provide model hit shapes, measured anchors, display-space curves and stroke width',()=>{
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}})};
+9 -1
View File
@@ -1,14 +1,22 @@
/**
* 图片解码池的并发、排队、取消与响应校验测试。尺寸元数据必须合法,调用者取消不能提前释放仍在运行的槽位。
* 使用解码执行器及 Worker 替身,不实际解码图片像素或启动浏览器线程。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createImageDecodePool} from '../../src/lib/canvas-runtime/image-decoder.ts';
import {createBrowserImageDecoder} from '../../src/lib/canvas-runtime/browser-images.ts';
// 手动完成的 Promise 控制解码退出;bytes 的 MIME 只是输入标签,替身执行器直接返回已知尺寸。
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};},bytes=new Blob(['image'],{type:'image/png'});
// 一个活动加一个等待名额,第三个拒绝;释放屏障后按 FIFO 完成,执行器计数峰值始终为一。
test('image decoder bounds real execution slots, preserves FIFO and rejects excess work',async()=>{
const gate=deferred(),started=[];let active=0,peak=0;const pool=createImageDecodePool({concurrency:1,maxQueued:1,execute:async file=>{active++;peak=Math.max(peak,active);started.push(await file.text());await gate.promise;active--;return{width:4,height:2};}});try{const first=pool.decode(bytes),second=pool.decode(new Blob(['second'],{type:'image/png'}));await assert.rejects(pool.decode(bytes),/image_decode_capacity/);gate.resolve();assert.deepEqual(await first,{width:4,height:2});assert.deepEqual(await second,{width:4,height:2});assert.deepEqual(started,['image','second']);assert.equal(peak,1);}finally{gate.resolve();pool.close();}
});
// 活动调用取消后,底层解码尚未结束时新任务仍排队;直到 gate 完成才启动下一次执行。
test('cancelling active work does not free its slot before the underlying decoder settles',async()=>{
const gate=deferred(),started=deferred(),cancel=new AbortController();let executions=0;const pool=createImageDecodePool({concurrency:1,execute:async()=>{executions++;started.resolve();await gate.promise;return{width:4,height:2};}});try{const first=pool.decode(bytes,cancel.signal),rejected=assert.rejects(first,/image_decode_aborted/);await started.promise;cancel.abort();await rejected;const second=pool.decode(bytes);await Promise.resolve();assert.equal(executions,1);gate.resolve();await second;assert.equal(executions,2);}finally{gate.resolve();pool.close();}
});
// 零宽尺寸不能成为可用元数据;Worker 返回错误请求 id 也要拒绝并终止线程替身,finally 恢复全局 Worker。
test('invalid dimensions and failed workers do not produce usable metadata; worker identity is checked',async()=>{
const invalid=createImageDecodePool({execute:async()=>({width:0,height:2})});await assert.rejects(invalid.decode(bytes),/invalid_image_dimensions/);invalid.close();const previous=globalThis.Worker;let terminated=0;globalThis.Worker=class{postMessage(message){queueMicrotask(()=>this.onmessage({data:{version:1,id:message.id+1,byteLength:message.bytes.size,width:1,height:1}}));}terminate(){terminated++;}};const pool=createBrowserImageDecoder();try{await assert.rejects(pool.decode(bytes),/invalid_image_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
const invalid=createImageDecodePool({execute:async()=>({width:0,height:2})});await assert.rejects(invalid.decode(bytes),/invalid_image_dimensions/);invalid.close();const previous=globalThis.Worker;let terminated=0;globalThis.Worker=class{/* 异步回信的尺寸合法,唯独 id 错误,应被视为其他请求的结果。 */ postMessage(message){queueMicrotask(()=>this.onmessage({data:{version:1,id:message.id+1,byteLength:message.bytes.size,width:1,height:1}}));}/* 错误响应必须清理 worker,不能留在池内继续复用。 */ terminate(){terminated++;}};const pool=createBrowserImageDecoder();try{await assert.rejects(pool.decode(bytes),/invalid_image_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
});
+5
View File
@@ -1,11 +1,16 @@
/**
* 导入发布批量的自适应控制测试。目标耗时保持不变,根据固定发布成本与每项成本增减批量,而不是固定每批若干项。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createImportBatches} from '../../src/lib/canvas-runtime/import-batches.ts';
// 从单项起步,在固定成本占主导且每项便宜时逐渐增至 32 上限;目标耗时仍为 6,不通过抬高预算换吞吐。
test('fixed publication cost can grow beyond singleton batches without raising the time budget',()=>{
const batches=createImportBatches(6,32);assert.equal(batches.observed,false);assert.equal(batches.limit,1);const sizes=[];
for(let i=0;i<20;i++){const n=batches.limit;sizes.push(n);batches.record(n,4+n*.05);}
assert.equal(batches.observed,true);assert.equal(batches.limit,32);assert.ok(sizes.includes(2));assert.ok(sizes.every(n=>n<=32));
});
// 不足一整批的尾项不用于虚增容量;昂贵整批迅速退回一项,后续单项成本降低后再渐进恢复。
test('expensive batches back off, then recover as costs fall; partial tails do not inflate the limit',()=>{
const batches=createImportBatches(6,32);for(let i=0;i<4;i++)batches.record(batches.limit,1);const full=batches.limit;batches.record(1,1);assert.equal(batches.limit,full);batches.record(full,60);assert.equal(batches.limit,1);batches.record(1,12);assert.equal(batches.limit,1);batches.record(1,2);assert.equal(batches.limit,2);batches.record(2,2);assert.equal(batches.limit,3);
});
@@ -1,13 +1,23 @@
/**
* 文档 parser 5 的渐进源测试:标准头部后可先输出前项,后续尾部仍可挂起、报错或尚未读取。
* 记录序号稳定,已就绪项不应被未来扫描阻塞;错误尾部只追加一条终态失败,不撤回合法前缀。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {openStreamingDocumentImportSource} from '../../src/lib/canvas-runtime/import-document-stream.ts';
// 为各次读取生成独立信号,测试可单独取消某个挂起读取而不使整个源永久不可用。
const signal=()=>new AbortController().signal;
// 最小标准 envelope 头部,后续用例在其后追加 elements/tombstones 或打乱字段顺序。
const header={documentFormatVersion:1,projectId:'source',canvases:[{id:'root',parent:null}]};
// 统一把对象/字符串转成 Blob,也接受可观测 Blob 子类;固定 fingerprint 并允许覆盖调度/字节端口。
const open=(bytes,options={})=>openStreamingDocumentImportSource({bytes:bytes instanceof Blob?bytes:new Blob([typeof bytes==='string'?bytes:JSON.stringify(bytes)]),fingerprint:'immutable',signal:signal(),...options});
// 手动 gate 和 entered 信号精确控制尾部 slice 返回,不依赖随意延时猜测并发时序。
const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return{promise,resolve};};
// 首项在尾部 gate 未打开时可读;未来项扫描挂起也不阻塞重读已就绪首项,取消后同序号可重新扫描成功。
test('canonical document opens and emits its first record without reading the gated payload tail; aborted scanning can retry the same ordinal',async()=>{
const gate=deferred(),entered=deferred(),reads=[];let block=true;
// 记录切片,前 32 KiB 立即返回,之后提供可暂停 arrayBuffer;用于证明打开及首项读取不访问挂起尾部。
class Source extends Blob{slice(start=0,end=this.size,type){reads.push([start,end]);const part=super.slice(start,end,type);if(start<32768||!block)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
const bytes=new Source([JSON.stringify({...header,elements:[{id:'first'},{id:'second',text:'x'.repeat(100000)}],tombstones:{}})]),reader=await open(bytes);
assert.equal(reader.parserVersion,5);assert.equal(reads.some(([start])=>start>=32768),false);
@@ -16,11 +26,13 @@ test('canonical document opens and emits its first record without reading the ga
assert.equal((await reader.read(1,signal())).value.record.id,'second');assert.equal(await reader.read(2,signal()),null);
});
// 子 canvas 优先、元素随后,并发/重复读取和重开都保持相同序号与根 alias;跳过未定义范围的游标拒绝。
test('reopening and concurrent reads keep stable canvas-first ordinals and original root aliases',async()=>{
const input={...header,canvases:[...header.canvases,{id:'nested',parent:{canvasId:'root',entryElementId:'frame'}}],elements:[{id:'frame'},{id:'member',ownerCanvasId:'nested',data:{text:'图,] \\"'}}],tombstones:{old:2}},reader=await open(input);
const rows=await Promise.all([0,1,2,1].map(position=>reader.read(position,signal())));assert.deepEqual(rows.map(row=>row.value.record.id),['nested','frame','member','frame']);assert.equal(rows[2].value.sourceRootId,'root');const reopened=await open(input);assert.deepEqual(await reopened.read(2,signal()),rows[2]);assert.equal(await reader.read(3,signal()),null);await assert.rejects(reader.read(5,signal()),/invalid_import_cursor/);
});
// 非法墓碑、垃圾、重复字段、未知尾字段及尾逗号不撤回合法元素,只追加一条可推进游标的错误后结束。
test('bad envelope tails preserve legal records and become one explicit terminal failure',async()=>{
for(const suffix of [',"tombstones":{"bad":0}}','} trailing',',"elements":[]}',',"unknown":true}',',}']){
const reader=await open(JSON.stringify({...header,elements:[{id:'first'},{id:'last'}]}).slice(0,-1)+suffix);
@@ -29,22 +41,27 @@ test('bad envelope tails preserve legal records and become one explicit terminal
const trailing=await open(JSON.stringify(header).slice(0,-1)+',"elements":[{"id":"first"},]}');assert.equal((await trailing.read(0,signal())).value.record.id,'first');assert.equal((await trailing.read(1,signal())).error,'invalid_document_import');
});
// 非标准字段顺序沿用索引兼容路径,但仍以 parser 5 暴露;未来格式、循环分区及缺失父 canvas 在打开时拒绝。
test('out-of-order headers retain indexed compatibility; invalid headers and cycles are rejected before publishing',async()=>{
const reader=await open({elements:[{id:'first'}],...header});assert.equal(reader.parserVersion,5);assert.equal((await reader.read(0,signal())).value.record.id,'first');
for(const input of [{...header,documentFormatVersion:2,elements:[]},{...header,canvases:[...header.canvases,{id:'cycle',parent:{canvasId:'cycle',entryElementId:'self'}}],elements:[]},{...header,canvases:[...header.canvases,{id:'lost',parent:{canvasId:'missing',entryElementId:'x'}}],elements:[]}])await assert.rejects(open(input),/invalid_document_import/);
});
// 巨型正文只扫描并报告 record_too_large,后续合法记录仍读出,到末尾正常结束。
test('oversized payload is scanned without decoding and later records still import',async()=>{
const reader=await open({...header,elements:[{id:'huge',text:'x'.repeat(300000)},{id:'next'}]});assert.equal((await reader.read(0,signal())).error,'record_too_large');assert.equal((await reader.read(1,signal())).value.record.id,'next');assert.equal(await reader.read(2,signal()),null);
});
// 1000 个小记录复用 16 KiB 扫描块,切片数量按文件块数增长而非记录数增长。
test('sequential records reuse the source scan chunk rather than reading a new 16 KiB window per row',async()=>{
// 只统计完整 16 KiB 扫描窗口请求,检查连续记录共用扫描块,而非把正文小范围读取也计为窗口。
let chunks=0;class Counted extends Blob{slice(start=0,end=this.size,...rest){if(end-start===16384)chunks++;return super.slice(start,end,...rest);}}
const input={...header,elements:Array.from({length:1000},(_,index)=>({id:`element-${index}`,text:'small'}))},bytes=new Counted([JSON.stringify(input)]),reader=await open(bytes);
for(let index=0;index<1000;index++)assert.equal((await reader.read(index,signal())).value.record.id,`element-${index}`);
assert.equal(await reader.read(1000,signal()),null);assert.ok(chunks<=Math.ceil(bytes.size/16384)+1,`${chunks} scan reads for ${bytes.size} bytes`);
});
// 首次 await 前捕获 bytes/fingerprint;调用者随后替换 options 字段不能改变正在打开的源及其记录。
test('source identity and bytes are captured before the first await, independent of caller option mutation',async()=>{
const options={bytes:new Blob([JSON.stringify({...header,elements:[{id:'original'}]})]),fingerprint:'original-file',signal:signal()},opening=openStreamingDocumentImportSource(options);options.bytes=new Blob([JSON.stringify({...header,elements:[{id:'replaced'}]})]);options.fingerprint='replacement-file';const reader=await opening;assert.equal(reader.fingerprint,'original-file');assert.equal((await reader.read(0,signal())).value.record.id,'original');assert.equal(await reader.read(1,signal()),null);
});
+11
View File
@@ -1,11 +1,22 @@
/**
* 完整文档 envelope 的索引导入源测试(parser 4)。头部/拓扑先验证,元素正文按稳定序号读取,原根 canvas 映射到目标根。
* 超大单条作为记录失败,不阻止后续合法项;文件尾结构损坏则拒绝打开完整索引源。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';import {openDocumentImportSource} from '../../src/lib/canvas-runtime/import-document.ts';
// 独立信号和 Blob 包装工厂;基线包含两层 canvas、Unicode/分隔符正文及墓碑,检查封装而非简单数组。
const signal=()=>new AbortController().signal,source=value=>new Blob([typeof value==='string'?value:JSON.stringify(value)]),document={documentFormatVersion:1,projectId:'source',canvases:[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'frame'}}],elements:[{id:'frame',data:{title:'中文 [,] "'}},{id:'inside',ownerCanvasId:'child'}],tombstones:{old:2}};
// 为每次打开绑定固定 fingerprint;重开序号读取应得到同样记录,不依赖上次内存游标。
const open=bytes=>openDocumentImportSource({bytes,fingerprint:'original-file',signal:signal()});
// 子 canvas 先输出,原根只作 alias;frame/member 以稳定序号读取,重开后同序号结果一致,到末尾返回 null。
test('export envelope retains nested canvas records, emits root alias and seeks payloads by stable ordinal',async()=>{const reader=await open(source(document));assert.equal(reader.parserVersion,4);const a=await reader.read(0,signal()),b=await reader.read(1,signal());assert.equal(a.value.kind,'canvas');assert.equal(a.value.record.id,'child');assert.equal(a.value.sourceRootId,'root');assert.deepEqual(b.value.record,document.elements[0]);const reopened=await open(source(document));assert.deepEqual(await reopened.read(2,signal()),await reader.read(2,signal()));assert.equal(await reader.read(3,signal()),null);});
// envelope 字段顺序可变,但未来版本、多根、缺失父 canvas、非法墓碑及重复键都不能生成可用源。
test('envelope fields may be reordered, while future versions, duplicate keys and broken topology reject',async()=>{const reordered={elements:document.elements,tombstones:{},canvases:document.canvases,projectId:'source',documentFormatVersion:1};assert.equal((await(await open(source(reordered))).read(1,signal())).value.record.id,'frame');for(const bad of [{...document,documentFormatVersion:2},{...document,canvases:[...document.canvases,{id:'other',parent:null}]},{...document,canvases:[{id:'root',parent:null},{id:'child',parent:{canvasId:'missing',entryElementId:'e'}}]},{...document,tombstones:{x:0}},'{"documentFormatVersion":1,"documentFormatVersion":1}'])await assert.rejects(open(source(bad)));});
// 超过记录上限时不解码巨型正文并报告 record_too_large,后续记录仍可读;尾部垃圾及预先取消明确拒绝。
test('payload errors are per record and large records are never decoded; malformed envelope tails reject',async()=>{const reader=await open(source({...document,elements:[{id:'huge',text:'x'.repeat(300000)},document.elements[1]]}));assert.equal((await reader.read(1,signal())).error,'record_too_large');assert.equal((await reader.read(2,signal())).value.record.id,'inside');await assert.rejects(open(source(JSON.stringify(document)+'garbage')));const controller=new AbortController();controller.abort();await assert.rejects(openDocumentImportSource({bytes:source(document),fingerprint:'x',signal:controller.signal}),/import_aborted/);});
import {prepareNativeImport} from '../../src/lib/canvas-runtime/import-native.ts';
import {createImportJob} from '../../src/lib/canvas-runtime/imports.ts';
import {createRegistry,createDocument} from '../../src/lib/canvas-core/index.ts';
// 原根 alias 同时映射 owner 与组件声明的 canvasRef;不能为原根再 reserve 一个多余的新 canvas ID。
test('document root aliases also remap component-declared canvas references without reserving a duplicate root',()=>{const registry=createRegistry([{id:'ref',dataVersion:1,structure:'node',features:[],validate:()=>true,copy:{references:[{path:['canvasRef'],target:'canvas',external:'reject'}],omit:[]}}]),target=createDocument('p',[{id:'target',parent:null}],[]),job=createImportJob({id:'job',projectId:'p',parserVersion:4,sourceFingerprint:'file',source:{kind:'native-document',fileId:'file',byteLength:100,targetCanvasId:'target'}}),reserved=[],record={id:'a',incarnation:1,ownerCanvasId:'source',componentId:'ref',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:10,height:10,rotation:0},data:{canvasRef:'source'}},unit=prepareNativeImport({cursor:0,nextCursor:1,key:'record:0',value:{kind:'element',key:'a',record,sourceRootId:'source'}},job,key=>{reserved.push(key);return'new-'+key;},target,registry);assert.equal(unit.changes[0].after.data.canvasRef,'target');assert.equal(unit.changes[0].after.ownerCanvasId,'target');assert.ok(!reserved.includes('canvas:source'));});
// 小于固定字节上限的扫描也要按实际耗时让出,yield 内取消后以 import_aborted 拒绝。
test('document preparation yields by elapsed time even for files below the byte ceiling',async()=>{const controller=new AbortController();let clock=0,yields=0;await assert.rejects(openDocumentImportSource({bytes:source({...document,elements:[{id:'slow',text:'x'.repeat(2048)}]}),fingerprint:'slow',signal:controller.signal,now:()=>clock+=10,frameBudgetMs:6,yieldTask:async()=>{yields++;controller.abort();}}),/import_aborted/);assert.equal(yields,1);});
@@ -1,13 +1,20 @@
/**
* 导入源文件退休元数据的不可变更新与身份保护测试。已取消 job 的源文件仍需完整引用扫描才能进入回收。
* 这里只验证标记、归属及归档演进,不实际删除文件字节。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.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()});
// 新增退休标记只生成新 runtime/job 并递增 generation;相同标记是 no-op,重新被引用或扫描不完整时清除退休资格。
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 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);
});
// 允许源文件向 previousFiles 归档,但拒绝丢失归属、与资源重名、重复 job 共享身份或回退已 purge 元数据;非法版本/时间也拒绝。
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);
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/);
+7 -1
View File
@@ -1,13 +1,19 @@
/**
* 导入稳定 ID 表的不可变捕获与增量分配测试。安全冻结表可缓存反向身份集合,外部可变/getter 表必须防御性捕获。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createImportIdentities} from '../../src/lib/canvas-runtime/import-identities.ts';
// 万项冻结表反复 reserve 已有键不分配新 ID,也不重复 ownKeys 扫描;clear 后下次捕获才重新扫描。
test('preallocated 10000-ID tables keep their identity across records without allocating or rescanning',()=>{
let reads=0;const raw=Object.freeze(Object.fromEntries(Array.from({length:10000},(_,i)=>['key'+i,'id'+i]))),ids=new Proxy(raw,{ownKeys(target){reads++;return Reflect.ownKeys(target);}}),capture=createImportIdentities(),allocate=()=>{throw Error('unexpected allocation');};
let reads=0;const raw=Object.freeze(Object.fromEntries(Array.from({length:10000},(_,i)=>['key'+i,'id'+i]))),ids=new Proxy(raw,{/* 只观测枚举次数,不改变表内容,用于识别不必要的万项重扫。 */ ownKeys(target){reads++;return Reflect.ownKeys(target);}}),capture=createImportIdentities(),allocate=()=>{throw Error('unexpected allocation');};
const first=capture(ids,allocate);assert.equal(first.reserve('key0'),'id0');assert.strictEqual(first.finish(),ids);const checked=reads;for(let i=0;i<10000;i++){const row=capture(ids,allocate);assert.equal(row.reserve('key'+i),'id'+i);assert.ok(row.has('id'+i));assert.strictEqual(row.finish(),ids);}assert.equal(reads,checked);capture.clear();capture(ids,allocate);assert.ok(reads>checked);
});
// 首次新增键才分离字典及反向集合,finish 结果不受之后 reserve 影响;兄弟捕获独立,重复目标 ID 必须拒绝。
test('first allocation detaches the dictionary and reverse set, and a late callback cannot change its published result',()=>{
const ids=Object.freeze({old:'old-id'}),capture=createImportIdentities();let next=0;const row=capture(ids,()=>`new-${++next}`);row.reserve('added');const result=row.finish();assert.notStrictEqual(result,ids);row.reserve('late');assert.deepEqual(result,{old:'old-id',added:'new-1'});assert.deepEqual(ids,{old:'old-id'});const sibling=capture(ids,()=> 'new-1');assert.equal(sibling.has('new-1'),false);assert.equal(sibling.reserve('other'),'new-1');assert.throws(()=>capture(ids,()=> 'old-id').reserve('duplicate'),/duplicate_import_identity/);
});
// 可变字典捕获当时值,不受随后外部改写影响;冻结 getter 仍逐次重新读取,不能仅凭 Object.freeze 复用动态值。
test('mutable and frozen getter dictionaries are captured defensively rather than reused',()=>{
const capture=createImportIdentities(),mutable={one:'first'},first=capture(mutable,()=> 'new');mutable.one='second';assert.equal(first.reserve('one'),'first');assert.deepEqual(first.finish(),{one:'first'});assert.equal(capture(mutable,()=> 'new').reserve('one'),'second');
let current='old',reads=0;const dynamic=Object.freeze({get one(){reads++;return current;}}),one=capture(dynamic,()=> 'new');current='changed';assert.equal(one.reserve('one'),'old');assert.equal(reads,1);assert.equal(capture(dynamic,()=> 'new').reserve('one'),'changed');assert.equal(reads,2);
+6 -1
View File
@@ -1,11 +1,16 @@
/**
* 导入源全文件 SHA-256 绑定与任务完整性版本测试。新任务在绑定摘要前不能声明已解析游标或稳定映射,旧格式按明确兼容规则读取。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
// 标准 abc 向量和不同输入区分完整源摘要;预先取消及异步读取返回前取消都拒绝,不接受迟到结果。
test('source SHA256 binds complete bytes and cancellation fences both sides of asynchronous reading',async()=>{
assert.equal(await importSourceDigest(new Blob(['abc'])),'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');assert.notEqual(await importSourceDigest(new Blob(['abc'])),await importSourceDigest(new Blob(['abd'])));
const controller=new AbortController();controller.abort();await assert.rejects(importSourceDigest(new Blob(['abc']),controller.signal),/import_aborted/);let release;const gate=new Promise(resolve=>{release=resolve;}),later=new AbortController(),bytes=new class extends Blob{async arrayBuffer(){await gate;return super.arrayBuffer();}}(['abc']),result=importSourceDigest(bytes,later.signal);later.abort();release();await assert.rejects(result,/import_aborted/);
const controller=new AbortController();controller.abort();await assert.rejects(importSourceDigest(new Blob(['abc']),controller.signal),/import_aborted/);let release;const gate=new Promise(resolve=>{release=resolve;}),later=new AbortController(),bytes=new class extends Blob{/* 此替身只覆写整 Blob 的 arrayBuffer;当前摘要走 slice().arrayBuffer(),故 gate 不拦截分块读取。本例断言调用后取消的拒绝,读块挂起场景另由 sha256.test.mjs 覆盖。 */ async arrayBuffer(){await gate;return super.arrayBuffer();}}(['abc']),result=importSourceDigest(bytes,later.signal);later.abort();release();await assert.rejects(result,/import_aborted/);
});
// 新 job 为 v3/integrityVersion=1,未绑定摘要前拒绝进度,非法摘要/未来完整性解释器拒绝;显式旧格式及已绑定新格式仍可校验。
test('new source jobs declare integrity version and reject progress without a bound hash; legacy data remains readable',()=>{
const job=createImportJob({id:'i',projectId:'p',sourceFingerprint:'file',parserVersion:1,source:{kind:'native-ndjson',fileId:'file',targetCanvasId:'canvas',byteLength:10}});assert.equal(job.version,3);assert.equal(job.source.integrityVersion,1);validateImportJob(job);
assert.throws(()=>validateImportJob({...job,cursor:1}),/import_source_integrity_missing/);assert.throws(()=>validateImportJob({...job,stableIds:{'element:A':'new-A'}}),/import_source_integrity_missing/);assert.throws(()=>validateImportJob({...job,source:{...job.source,sha256:'invalid'}}),/invalid_import_source/);assert.throws(()=>validateImportJob({...job,source:{...job.source,integrityVersion:3}}),/import_source_integrity_interpreter_unavailable/);
+12
View File
@@ -1,9 +1,21 @@
/**
* JSON 数组渐进读取测试。按字节游标扫描 UTF-8 与嵌套结构,可保留已接受前缀并将坏尾部作为显式失败行。
* 读取器还需限制单条解码大小,在固定字节工作量或实际耗时达到预算时让出。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';import {createJsonArrayImportSource} from '../../src/lib/canvas-runtime/import-json-array.ts';
// 每次读取提供独立未取消信号,取消用例显式使用自己的 controller。
const signal=()=>new AbortController().signal;
// 把文本包装成 Blob,绑定 fingerprint,并允许各用例覆盖 chunkBytes、单条上限及调度端口。
function source(text,options={}){return createJsonArrayImportSource({bytes:new Blob([text]),fingerprint:'immutable-source',...options});}
// 以 nextCursor 顺序排空且断言游标递增,最多 100 行,避免测试在读取器不前进时无限循环。
async function all(reader){let cursor=0;const rows=[];for(let i=0;i<100;i++){const row=await reader.read(cursor,signal());if(!row)return rows;assert.ok(row.nextCursor>cursor);rows.push(row);cursor=row.nextCursor;}throw Error('reader did not terminate');}
// 每字节分块处理中文、转义引号和字符串中的括号/逗号,结果与原生 JSON 相同;换块宽重开仍能从旧字节游标继续。
test('array reader handles UTF8, escaped strings and nested delimiters across one-byte chunks and resumes from the same byte cursor',async()=>{const text=' [ {"title":"图\\\"片,]","nested":[1,{"x":"]"}]},\n {"title":"second"} ] ',reader=source(text,{chunkBytes:1}),rows=await all(reader);assert.deepEqual(rows.map(r=>r.value),JSON.parse(text));assert.ok(rows.every(r=>!r.error));const reopened=source(text,{chunkBytes:3});assert.deepEqual(await reopened.read(rows[1].cursor,signal()),rows[1]);assert.equal(await reopened.read(rows[1].nextCursor,signal()),null);});
// 缺失/多余分隔和尾部垃圾保留已接受第一项,随后报告失败;空数组正常,空文件或非法尾逗号明确拒绝。
test('structurally malformed tails report failure after the already accepted prefix',async()=>{for(const tail of [',]',',','','] garbage']){const rows=await all(source('[{"id":1},{"id":2}'+tail));assert.deepEqual(rows[0].value,{id:1});assert.ok(rows.at(-1).error,tail);}assert.deepEqual(await all(source('[]')),[]);await assert.rejects(all(source('')),/invalid_json_array/);assert.equal((await all(source('[1,]')))[0].error,'invalid_json_array');});
// 超大字符串只扫描不解码,报告单条错误后仍读到后续对象;非法 UTF-8 保留明确错误。
test('oversized records skip bounded payload and allow a later valid record; bad UTF8 remains explicit',async()=>{const rows=await all(source(JSON.stringify(['x'.repeat(200),{ok:true}]),{maxRecordBytes:32,chunkBytes:8}));assert.equal(rows[0].error,'record_too_large');assert.deepEqual(rows[1].value,{ok:true});const reader=createJsonArrayImportSource({bytes:new Blob([new Uint8Array([91,34,255,34,93])]),fingerprint:'bad'});assert.equal((await all(reader))[0].error,'invalid_utf8');});
// 长扫描在工作量边界让出,yield 内取消不能返回已完成记录;负字节游标拒绝。
test('long scans yield and honor abort without publishing a completed record',async()=>{const controller=new AbortController();let yields=0;const reader=source(JSON.stringify(['x'.repeat(40000)]),{now:()=>0,yieldTask:async()=>{yields++;controller.abort();}});await assert.rejects(reader.read(0,controller.signal),/import_aborted/);assert.equal(yields,1);await assert.rejects(reader.read(-1,signal()),/invalid_import_cursor/);});
// 即使只扫描 2048 字符,耗时预算先到也应让出,取消错误保持 import_aborted。
test('slow array scans yield by elapsed time before the fixed byte ceiling and abort at that boundary',async()=>{const controller=new AbortController();let clock=0,yields=0;const reader=source(JSON.stringify(['x'.repeat(2048)]),{now:()=>clock+=10,frameBudgetMs:6,yieldTask:async()=>{yields++;controller.abort();}});await assert.rejects(reader.read(0,controller.signal),/import_aborted/);assert.equal(yields,1);});
+24 -6
View File
@@ -1,3 +1,7 @@
/**
* 导入按块读取证明测试。证明只记录成功消费范围涉及的对齐块摘要,不为尚未读到的文件尾部提供真实性保证。
* 原始块缓存可淘汰,但预期摘要保留;共享读取、取消、关闭与容量限制不能提前发布半份读取证据。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createImportReadProof,validateImportReadProof,IMPORT_PROOF_BLOCK_BYTES as B} from '../../src/lib/canvas-runtime/import-read-proof.ts';
@@ -6,10 +10,14 @@ import {openStreamingDocumentImportSource} from '../../src/lib/canvas-runtime/im
import {createNdjsonImportSource} from '../../src/lib/canvas-runtime/imports.ts';
import {createJsonArrayImportSource} from '../../src/lib/canvas-runtime/import-json-array.ts';
import {fault} from '../../src/lib/canvas-core/values.ts';
// 独立读取信号和可控 Promise,分别控制取消者、其他等待者及底层原生读取返回。
const signal=()=>new AbortController().signal,deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return{promise,resolve};};
// 使用 WebCrypto 独立计算预期块摘要,对照证明中记录的 SHA-256。
const sha=async bytes=>Buffer.from(await crypto.subtle.digest('SHA-256',bytes)).toString('hex');
// 让出一次真实 timer,使不可取消的底层读在 gate 释放后有机会完成清理。
const tick=()=>new Promise(resolve=>setTimeout(resolve,0));
// 跨两个块的范围返回精确且独立的字节副本,调用者修改不影响重读;证明只含消费过的对齐块,缓存可仅驻留一个块。
test('range reads return exact owned bytes and checkpoint only the aligned blocks they consumed',async()=>{
const data=Uint8Array.from({length:B*3+19},(_,i)=>i%251),bytes=new Blob([data]),reader=createImportReadProof({bytes,fileId:'f',maxResidentBlocks:1});
const value=await reader.port.read(B-5,B+7,signal());assert.deepEqual(value,data.slice(B-5,B+7));value.fill(0);assert.deepEqual(await reader.port.read(B-5,B+7,signal()),data.slice(B-5,B+7));
@@ -17,52 +25,61 @@ test('range reads return exact owned bytes and checkpoint only the aligned block
assert.deepEqual(validateImportReadProof(JSON.parse(JSON.stringify(proof)),'f',bytes.size),proof);reader.close();assert.equal(reader.stats().residentBlocks,0);
});
// 尾部块读取不可用时,parser 5 仍返回首项;证明仅含第一块,不需要先认证整份文件。
test('parser 5 emits its first proof-backed record while the unread tail is unavailable',async()=>{
const reads=[];class Gated extends Blob{slice(start=0,end=this.size){reads.push([start,end]);if(start>=B)throw Error('tail unavailable');return super.slice(start,end);}}
const reads=[];/* 记录原始块请求,并让首块之后的读取立即失败,检验首项是否提前依赖尾部。 */ class Gated extends Blob{slice(start=0,end=this.size){reads.push([start,end]);if(start>=B)throw Error('tail unavailable');return super.slice(start,end);}}
const bytes=new Gated([JSON.stringify({documentFormatVersion:1,projectId:'p',canvases:[{id:'root',parent:null}],elements:[{id:'first'},{id:'later',text:'x'.repeat(B*3)}]})]),reader=createImportReadProof({bytes,fileId:'f'});
const source=await openStreamingDocumentImportSource({bytes,readBytes:reader.port,fingerprint:'f',signal:signal()});assert.equal((await source.read(0,signal())).value.record.id,'first');assert.deepEqual(reader.getProof().blocks.map(block=>block.index),[0]);assert.equal(reads.some(([start])=>start>=B),false);reader.close();
});
// NDJSON、数组和乱序文档兼容路径均通过注入的 readBytes 端口读取,并形成实际消费证明。
test('NDJSON, array and out-of-order document fallback all use the byte reader',async()=>{
const cases=[['{"id":"a"}\n',o=>createNdjsonImportSource(o)],['[{"id":"a"}]',o=>createJsonArrayImportSource(o)],[JSON.stringify({elements:[{id:'a'}],documentFormatVersion:1,projectId:'p',canvases:[{id:'root',parent:null}]}),o=>openStreamingDocumentImportSource({...o,signal:signal()})]];
for(const[text,open]of cases){const bytes=new Blob([text]),reader=createImportReadProof({bytes,fileId:'f'}),source=await open({bytes,readBytes:reader.port,fingerprint:'f'}),row=await source.read(0,signal());assert.equal((row.value.record??row.value).id,'a');assert.equal(reader.getProof().blocks.length,1);reader.close();}
});
// 恢复证明只重验已消费的第 0/2 块,变化即拒绝;未消费第 1 块变化不误报已验证,首次读取时才纳入新证明。
test('restored proof rejects changed consumed bytes and does not claim to authenticate unread blocks',async()=>{
const data=new Uint8Array(B*3).fill(7),first=createImportReadProof({bytes:new Blob([data]),fileId:'f'});await first.port.read(0,1,signal());await first.port.read(B*2,B*2+1,signal());const proof=first.getProof();first.close();
const changed=data.slice();changed[B*2]=8;const bad=createImportReadProof({bytes:new Blob([changed]),fileId:'f',proof});await assert.rejects(bad.verify(signal()),/import_source_changed/);assert.strictEqual(bad.getProof().blocks.length,2);bad.close();
changed[B*2]=7;changed[B]=8;const unseen=createImportReadProof({bytes:new Blob([changed]),fileId:'f',proof});await unseen.verify(signal());assert.deepEqual(unseen.getProof(),proof);assert.equal((await unseen.port.read(B,B+1,signal()))[0],8);assert.equal(unseen.getProof().blocks.length,3);unseen.close();
});
// 原始字节淘汰不丢预期 hash;已消费块后来篡改时拒绝且不污染旧证明,恢复原字节后可重试。
test('eviction does not discard the expected hash; changed rereads fail without poisoning a retry',async()=>{
const data=new Uint8Array(B*2).fill(4);let altered=false;class Mutable extends Blob{slice(start,end){const part=data.slice(start,end);if(altered&&start===0)part[0]=9;return new Blob([part]);}}
const data=new Uint8Array(B*2).fill(4);let altered=false;/* 人为违反 Blob 字节稳定性;淘汰缓存后重读首块会见到改变,用于校验保存的预期摘要。 */ class Mutable extends Blob{slice(start,end){const part=data.slice(start,end);if(altered&&start===0)part[0]=9;return new Blob([part]);}}
const reader=createImportReadProof({bytes:new Mutable([data]),fileId:'f',maxResidentBlocks:1});await reader.port.read(0,1,signal());await reader.port.read(B,B+1,signal());const proof=reader.getProof();altered=true;await assert.rejects(reader.port.read(0,1,signal()),/import_source_changed/);assert.strictEqual(reader.getProof(),proof);altered=false;assert.equal((await reader.port.read(0,1,signal()))[0],4);reader.close();
});
// 两个 waiter 共享同一原生块读取;取消一个不取消另一个,也不提前推进 proof,剩余 waiter 成功后才提交块证据。
test('one canceled waiter cannot cancel a shared block or advance its checkpoint',async()=>{
const gate=deferred();let reads=0;class Slow extends Blob{slice(start,end){const part=super.slice(start,end);return{async arrayBuffer(){reads++;await gate.promise;return part.arrayBuffer();}};}}
const gate=deferred();let reads=0;/* 把底层块读取停在 gate,模拟调用者已取消但不可取消的原生 I/O 尚未返回。 */ class Slow extends Blob{slice(start,end){const part=super.slice(start,end);return{async arrayBuffer(){reads++;await gate.promise;return part.arrayBuffer();}};}}
const reader=createImportReadProof({bytes:new Slow([new Uint8Array(B)]),fileId:'f'}),controller=new AbortController(),a=reader.port.read(0,1,controller.signal),b=reader.port.read(1,2,signal());controller.abort();await assert.rejects(a,/import_aborted/);assert.equal(reader.getProof().blocks.length,0);assert.equal(reader.stats().pendingBlocks,1);gate.resolve();await b;assert.equal(reads,1);assert.equal(reader.getProof().blocks.length,1);reader.close();
});
// 跨块范围取消后不发布已读首块的部分证据;close 幂等拒绝新/挂起调用,底层读真正退出后才清空 pending/resident。
test('canceled multi-block reads and close never publish partial read evidence',async()=>{
const gate=deferred(),entered=deferred();class Slow extends Blob{slice(start,end){const part=super.slice(start,end);if(start===0)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
const gate=deferred(),entered=deferred();/* 把底层块读取停在 gate,模拟调用者已取消但不可取消的原生 I/O 尚未返回。 */ class Slow extends Blob{slice(start,end){const part=super.slice(start,end);if(start===0)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
const reader=createImportReadProof({bytes:new Slow([new Uint8Array(B*2)]),fileId:'f'}),controller=new AbortController(),read=reader.port.read(0,B+1,controller.signal);await entered.promise;controller.abort();await assert.rejects(read,/import_aborted/);assert.equal(reader.getProof().blocks.length,0);
const late=reader.port.read(B,B+1,signal());reader.close();reader.close();await assert.rejects(late,/import_source_closed/);assert.equal(reader.stats().pendingBlocks,1);gate.resolve();await tick();assert.equal(reader.stats().pendingBlocks,0);assert.equal(reader.stats().residentBlocks,0);assert.equal(reader.getProof().blocks.length,0);await assert.rejects(reader.port.read(0,0,signal()),/import_source_closed/);
});
// 四个不可取消的原生读取已占容量,即使调用者全 abort 也不能新开第五个;底层退出后才释放,取消读取不进证明。
test('bounded admission retains occupied native reads until settlement instead of opening more slots on abort',async()=>{
const gate=deferred();let reads=0;class Slow extends Blob{slice(start,end){const part=super.slice(start,end);return{async arrayBuffer(){reads++;await gate.promise;return part.arrayBuffer();}};}}
const gate=deferred();let reads=0;/* 把底层块读取停在 gate,模拟调用者已取消但不可取消的原生 I/O 尚未返回。 */ class Slow extends Blob{slice(start,end){const part=super.slice(start,end);return{async arrayBuffer(){reads++;await gate.promise;return part.arrayBuffer();}};}}
const reader=createImportReadProof({bytes:new Slow([new Uint8Array(B*5)]),fileId:'f',maxResidentBlocks:2}),controllers=Array.from({length:4},()=>new AbortController()),pending=controllers.map((controller,i)=>reader.port.read(B*i,B*i+1,controller.signal));
await assert.rejects(reader.port.read(B*4,B*4+1,signal()),/import_read_capacity/);assert.equal((await reader.port.read(B*4,B*4,signal())).byteLength,0);controllers.forEach(controller=>controller.abort());await Promise.all(pending.map(task=>assert.rejects(task,/import_aborted/)));assert.equal(reader.stats().activeReads,0);assert.equal(reader.stats().pendingBlocks,4);await assert.rejects(reader.port.read(B*4,B*4+1,signal()),/import_read_capacity/);assert.equal(reads,4);gate.resolve();await tick();assert.ok(reader.stats().residentBlocks<=2);assert.equal(reader.getProof().blocks.length,0);reader.close();
});
// 零长度范围不读块、不新增证明;未来/错身份/错长度/错块宽/额外字段/非法摘要与范围全部拒绝。
test('zero-length reads do no I/O and invalid proof, identity and ranges fail closed',async()=>{
let reads=0;class Count extends Blob{slice(...args){reads++;return super.slice(...args);}}
let reads=0;/* 对 slice 计数,证明零长度读取不触发底层 I/O。 */ class Count extends Blob{slice(...args){reads++;return super.slice(...args);}}
const bytes=new Count(['abc']),reader=createImportReadProof({bytes,fileId:'f'});await reader.port.read(1,1,signal());await reader.port.read(3,3,signal());assert.equal(reads,0);assert.equal(reader.getProof().blocks.length,0);const proof=reader.getProof();
for(const bad of [{...proof,version:2},{...proof,fileId:'other'},{...proof,byteLength:4},{...proof,blockBytes:1},{...proof,extra:true},{...proof,blocks:[{index:1,sha256:'a'.repeat(64)}]},{...proof,blocks:[{index:0,sha256:'A'.repeat(64)}]},{...proof,blocks:[{index:0,sha256:'a'.repeat(64)},{index:0,sha256:'a'.repeat(64)}]}])assert.throws(()=>createImportReadProof({bytes,fileId:'f',proof:bad}),/import_read_proof/);
for(const[start,end]of [[-1,1],[2,1],[0,4],[.5,1]])await assert.rejects(reader.port.read(start,end,signal()),/invalid_import_byte_range/);reader.close();
});
// 字节端口返回过短结果或源长度不符时拒绝;完整性错误不能被包装为 UTF-8 格式错误,修复后同记录可重试。
test('byte ports cannot return short bytes, wrong source length, or disguise integrity errors as UTF-8 failures',async()=>{
const bytes=new Blob(['abcdef']);await assert.rejects(readImportRange(bytes,{byteLength:6,async read(){return new Uint8Array(1);}},0,2,signal()),/import_source_changed/);await assert.rejects(readImportRange(bytes,{byteLength:7,async read(){throw Error('must not run');}},0,2,signal()),/invalid_import_byte_range/);
const document=new Blob([JSON.stringify({documentFormatVersion:1,projectId:'p',canvases:[{id:'root',parent:null}],elements:[{id:'a'}]})]);let rejectDecode=false;const port={byteLength:document.size,async read(start,end){if(rejectDecode&&end-start<100)throw fault('import_source_changed');return new Uint8Array(await document.slice(start,end).arrayBuffer());}};
@@ -70,6 +87,7 @@ test('byte ports cannot return short bytes, wrong source length, or disguise int
});
// 未来块临时 I/O 失败暂停该次读取,不把序号推进成终态坏尾部;恢复网络后重读同序号获得 b,再正常 EOF。
test('transient byte I/O failure does not advance a streaming ordinal into a terminal malformed-tail error',async()=>{
const bytes=new Blob([JSON.stringify({documentFormatVersion:1,projectId:'p',canvases:[{id:'root',parent:null}],elements:[{id:'a'},{id:'b',text:'x'.repeat(B*2)}]})]);let fail=true;
const readBytes={byteLength:bytes.size,async read(start,end){if(fail&&start>=B)throw Error('offline');return new Uint8Array(await bytes.slice(start,end).arrayBuffer());}};
+117 -9
View File
@@ -1,3 +1,9 @@
/**
* ProjectEngine 的渐进导入/深复制集成测试,串联本地文档、history、ledger、源文件、校验及恢复。
* 已发布内容可早于服务端 ACK;分块读取证明支持先显示,完整 SHA 与其持久化 seal 决定何时解除相关网络写入门槛。
* 暂停/关闭/取消、端点换代、旧解释器、资源引用及源文件回收各有独立生命周期,不能用重新插入全部内容来恢复。
* fixture 使用内存存储和幂等假服务端;测试验证引擎协调,不等同于真实 IDB 事务、网络或渲染帧率验证。
*/
import {encodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
import {test} from 'node:test';
@@ -5,29 +11,64 @@ import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
// 内置类型、空单 canvas 基线及固定作用域,关闭/重开读取同一个项目的恢复 image。
const registry=createRegistry(builtinComponents()),original=createDocument('project',[{id:'canvas',parent:null}],[]),scope={accountId:'account',workspaceId:'workspace',projectId:'project'};
// native NDJSON 记录工厂故意携带第八代身份和 runId;导入应生成新身份并去掉执行任务字段。
const node=id=>({kind:'element',key:id,record:{id,incarnation:8,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:id,text:'',runId:'must not run'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}});
// 对象序列编码为 NDJSON,也允许传入原始坏行以检查部分失败。
const file=records=>new Blob([records.map(value=>typeof value==='string'?value:JSON.stringify(value)).join('\n')]);
// 手动 gate 与 entered 信号控制服务端 ACK、摘要、源读取或持久化,避免靠固定延时决定事件顺序。
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
// 准备可重用的源文件/image/服务端文档和回执;broken 在写入前抛配额错误,gate 可挂起服务端提交。
function fixture(initial=original){
let image=null,leader,next=0,server=initial;const versions=new Map([...initial.canvases.keys()].map(id=>[id,1]));const files=new Map(),requests=[],receipts=new Map(),errors=[];
const f={files,requests,errors,gate:null,broken:false,
storage:{fileDeletion:'atomic-v1',async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},close(){}},
port:{compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},canonical(request){return receipts.get(request.id);},async read(){return{document:server,versions:new Map(versions)};}},
image:()=>image,server:()=>server,open:(extra={})=>openProjectEngine({scope,registry,document:initial,versions:new Map([...initial.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f;
storage:{fileDeletion:'atomic-v1',
// 单项目仅一个 leader;release 清空 owner 并撤销活动标记,允许关闭后重新领取。
async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},
// 克隆上次 image;用例重开应通过恢复 codec 读取它,而非复用旧引擎状态。
async recover(){return image&&structuredClone(image);},
// 先核对 leader 并检查配额,再保存源文件/删除/克隆 image;此内存实现不模拟真实事务的部分失败回滚。
async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},
// 按 ID 返回持久化 Blob,且要求当前 leader;用例可替换为挂起读取、损坏字节或 unavailable。
async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},
// 内存端口无外部句柄;引擎负责归还 leader。
close(){}},
port:{
// 编译携带意图和 requestId 的候选请求,参与 canvas 使用当前 ACK 版本,未建立的新 canvas 基线为零。
compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},
// 记录请求并可卡在 gate;同 requestId 已有 receipt 时不再次应用,模拟后端幂等,canonical 使用该次保存的基线。
async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},
// 按请求 ID 查询执行事实;无保存回执时保持 unresolved,不推定零写入。
async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},
// 取对应请求保存的 document/versions,防止用当前 working 冒充服务端确认。
canonical(request){return receipts.get(request.id);},
// 恢复会话读取当前服务端文档及版本表副本,避免外部修改同一个 Map。
async read(){return{document:server,versions:new Map(versions)};}},
// 暴露 fixture 保存的 image 引用,专供旧格式改造和损坏注入;不是产品公开状态接口。
image:()=>image,
// 只读取得当前假服务端文档,供断言本地可见与远端 ACK 之间的差别。
server:()=>server,
// 每次以相同源存储/作用域重新装配引擎;递增 ID 分配器和错误列表跨重开保留。
open:(extra={})=>openProjectEngine({scope,registry,document:initial,versions:new Map([...initial.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f;
}
// 先检查当前快照,再订阅到谓词成立或 5 秒超时;两种结束路径都移除订阅,避免泄漏监听器。
function until(engine,predicate){if(predicate(engine.getSnapshot()))return Promise.resolve();return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{unsubscribe();reject(Error('import condition timed out'));},5000),unsubscribe=engine.editor.subscribe((_,snapshot)=>{if(predicate(snapshot)){clearTimeout(timer);unsubscribe();resolve();}});});}
// 阻塞后端提交时三个 native 元素已本地可见且历史合并为一组;新 incarnation=1,runId 被剔除,放行 ACK 后再检查服务端。
test('native import adds visible records while its backend writer is still awaiting ACK',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred();f.gate=gate;
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.server().elements.size,0);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(f.files.size,1);for(const element of engine.getSnapshot().document.elements.values()){assert.equal(element.incarnation,1);assert.equal(element.data.runId,undefined);}gate.resolve();await engine.settle();assert.equal(f.server().elements.size,3);assert.equal(f.errors.length,0);decodeRecovery(f.image(),registry);}finally{gate.resolve();await engine.close();}
});
// 首项后暂停、编辑已导入元素再重开,继续使用持久化源与原 ID,后续导入不能覆盖手动标题。
test('pause/reopen/resume reuses the durable source and IDs without overwriting a user edit',async()=>{
const f=fixture();let engine=await f.open();
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:one'];assert.equal(job.status,'paused');engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'user edit'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'user edit');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.errors.length,0);decodeRecovery(f.image(),registry);}finally{await engine.close();}
});
// 导入前缀、手动编辑、导入后缀是三个有序 undo 单元;重开后逐项 undo/redo 仍保留同一编辑与任务完成事实。
test('import history on either side of a manual edit remains three ordered undo units after reopening',async()=>{
const f=fixture();let engine=await f.open();
try{
@@ -42,64 +83,76 @@ test('import history on either side of a manual edit remains three ordered undo
}finally{await engine.close();}
});
// 取消只保留已加入首项;重开后显式 resume 也不把 cancelled 任务当暂停任务继续插入。
test('cancel keeps the already added prefix and survives reopening without further insertion',async()=>{
const f=fixture();let engine=await f.open();
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.cancel(id);await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,1);await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);}finally{await engine.close();}
});
// 首个端点导入后被用户删除,晚到连线不能通过恢复旧源把它复活;剩余独立节点导入,任务 partial。
test('deleting an imported endpoint while paused prevents a late edge from reviving it',async()=>{
const f=fixture();let engine=await f.open();
const edge={kind:'element',key:'edge',record:{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'$target',source:{elementId:'one'},target:{elementId:'two'},path:null,data:{}}};
try{const id=engine.imports.startNdjson(file([node('one'),edge,node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:one'];engine.editor.dispatch({type:'element.remove',target:{id:first,incarnation:1}});await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.equal(engine.getSnapshot().document.elements.has(first),false);assert.equal(engine.getSnapshot().document.elements.size,1);assert.ok(job.failures.some(failure=>failure.key==='element:edge'));assert.equal(f.server().elements.has(first),false);}finally{await engine.close();}
});
// prepareRecovery 暂停活动导入发布,等待期间 rootRevision 不变;接纳新基线后才继续完成导入。
test('canonical recovery holds active imports and releases them after accepting the fresh base',async()=>{
const f=fixture(),engine=await f.open();
try{const id=engine.imports.startNdjson(file(Array.from({length:20},(_,index)=>node(`n-${index}`))),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length>=1);const session=await engine.prepareRecovery(),cut=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,cut);assert.equal((await session.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,20);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 关闭活动导入保存可继续的 running 检查点,重开自动续跑,首项 ID 不变且无重复节点。
test('closing an active import preserves a running checkpoint and reopening resumes without duplicate records',async()=>{
const f=fixture();let engine=await f.open();
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);const first=engine.imports.getJobs().get(id).stableIds['element:one'];await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 端点删除后 undo 恢复成第二代,旧连线仍不得绑定新身份;记录 import_dependency_changed,保留当前元素。
test('a late connection cannot claim an endpoint restored into a new incarnation',async()=>{
const f=fixture(),engine=await f.open(),edge={kind:'element',key:'edge',record:{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'$target',source:{elementId:'one'},target:{elementId:'two'},path:null,data:{}}};
try{const id=engine.imports.startNdjson(file([node('one'),edge,node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:one'];engine.editor.dispatch({type:'element.remove',target:{id:first,incarnation:1}});await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(first).incarnation,2);await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.equal(engine.getSnapshot().document.elements.get(first).incarnation,2);assert.equal(engine.getSnapshot().document.elements.has(job.stableIds['element:edge']),false);assert.ok(job.failures.some(failure=>failure.code==='import_dependency_changed'));}finally{await engine.close();}
});
// 重开读取源仍挂起时取消 job,随后返回 source unavailable 不能改写 cancelled 或产生无关错误。
test('a late unavailable-source result cannot change a job cancelled during reopening',async()=>{
const f=fixture();let engine=await f.open();const gate=deferred(),entered=deferred();
try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.persist();await engine.close();f.storage.readFile=async()=>{entered.resolve();return gate.promise;};engine=await f.open();await entered.promise;await engine.imports.cancel(id);gate.resolve(undefined);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(f.errors.length,0);}finally{gate.resolve(undefined);await engine.close();}
});
// 普通空恢复 image 可为格式 1,含读取证明后升级到 17;不能仅改 formatVersion 就把证明数据降成旧格式。
test('proof-bearing recovery cuts use format 17 and cannot be silently downgraded to a legacy image',async()=>{
const f=fixture(),engine=await f.open();
try{await engine.persist();assert.equal(f.image().formatVersion,1);const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const image=f.image();assert.equal(image.formatVersion,17);assert.equal(decodeRecovery(image,registry).runtime.imports.get(id).status,'completed');assert.throws(()=>decodeRecovery({...image,formatVersion:1},registry),/unsupported_recovery_version/);}finally{await engine.close();}
});
// 源文件首次保存挂起时首项已可见,但文件未落盘且任何后端请求不得越过屏障;放行后才完成同步。
test('the first valid import record displays while the source write is pending, but no backend request crosses that barrier',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),commit=f.storage.commit;f.storage.commit=async(owner,batch)=>{if(batch.files?.length){entered.resolve();await gate.promise;}return commit(owner,batch);};
try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await entered.promise;await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length>=1);assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.requests.length,0);assert.equal(f.files.size,0);gate.resolve();await engine.settle();assert.equal(f.files.size,1);assert.equal(f.server().elements.size,2);assert.equal(engine.imports.getJobs().get(id).status,'completed');}finally{gate.resolve();await engine.close();}
});
// 源文件写失败使任务暂停并保留本地首项/ID,网络未发;修复存储后 resume 唤醒原可靠写入流程。
test('resuming after a failed source write preserves IDs and wakes the original durable writer',async()=>{
const f=fixture(),engine=await f.open();
try{await engine.persist();f.broken=true;const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const first=engine.imports.getJobs().get(id).stableIds['element:one'];assert.equal(f.requests.length,0);assert.equal(engine.getSnapshot().document.elements.size,1);f.broken=false;await engine.imports.resume(id);await engine.settle();await until(engine,()=>f.server().elements.size===2);assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(f.files.size,1);assert.equal(new Set(f.requests.map(request=>request.id)).size,f.requests.length);}finally{f.broken=false;await engine.close();}
});
// 深复制样本按组件添加共享图片资源和付费任务字段,区分可复用的资源与不能延续的执行状态。
const copyNode=(id,owner='canvas',componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'source',...(componentId==='image'?{assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task'}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}});
// 用一个事务建立上级 C、入口 F、child 成员 A/B 及 C→F 入边,等待同步后作为复制基线。
async function seedCopy(engine){
const records=[copyNode('C'),copyNode('F','canvas','frame'),copyNode('A','child','image'),copyNode('B','child')];
const edge={id:'incoming',incarnation:1,structure:'connection',ownerCanvasId:'canvas',componentId:'connection',dataVersion:1,source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}};
const result=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:records[0]});tx.dispatch({type:'element.create',element:records[1]});tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}});for(const element of records.slice(2))tx.dispatch({type:'element.create',element});tx.dispatch({type:'element.create',element:edge});},{label:'Seed copy source'});assert.equal(result.status,'applied');await engine.settle();
}
// 深复制暂停/重开后使用同一源及映射,保留副本上用户编辑、上级入边、共享资源及剔除任务字段的结果。
test('deep copy source/mapping recover and preserve upper incoming source, shared assets and user edits',async()=>{
const f=fixture();let engine=await f.open();
try{
@@ -113,6 +166,7 @@ test('deep copy source/mapping recover and preserve upper incoming source, share
}finally{await engine.close();}
});
// 外部上级 C 删除再恢复成新一代,晚到复制入边报 copy_external_changed;独立子节点副本仍保留。
test('late copied edge cannot attach to an external endpoint deleted and restored as a new incarnation',async()=>{
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();
assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'C',incarnation:1}}).status,'applied');assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('C').incarnation,2);await engine.settle();await engine.imports.resume(id);await engine.settle();
@@ -120,29 +174,35 @@ test('late copied edge cannot attach to an external endpoint deleted and restore
}finally{await engine.close();}
});
// 复制任务取消后仅保留已发布前缀,重开/resume 不再新增副本。
test('copy cancellation retains the published prefix and does not resume on reopening',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const initial=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,initial+1);}finally{await engine.close();}
});
// 尚未准入前的预取消或项目关闭,迟到复制准备结果不能创建 running job;重开后任务表仍为空。
test('preparation abort and project close do not admit a late copy job',async()=>{
const f=fixture(),engine=await f.open();await seedCopy(engine);const controller=new AbortController();controller.abort();await assert.rejects(engine.imports.duplicate([{id:'F',incarnation:1}],{signal:controller.signal}),/copy_aborted/);assert.equal(engine.imports.getJobs().size,0);
const pending=engine.imports.duplicate([{id:'F',incarnation:1}]);const rejected=assert.rejects(pending,/copy_aborted|engine_closing/);await engine.close();await rejected;const reopened=await f.open();try{assert.equal(reopened.imports.getJobs().size,0);}finally{await reopened.close();}
});
// 复制本地完成不等待 ACK;任务显示关联发起 viewCanvasId,而复制元素仍归源规则决定的 child,不能混淆二者。
test('copy publishes independently of backend ACK and associates task visibility with its initiating view',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred();try{await seedCopy(engine);f.gate=gate;const before=f.server().elements.size,id=await engine.imports.duplicate([{id:'A',incarnation:1}],{viewCanvasId:'canvas'});await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');const job=engine.imports.getJobs().get(id);assert.equal(job.source.targetCanvasId,'canvas');assert.equal(engine.getSnapshot().document.elements.get(job.stableIds['element:A']).ownerCanvasId,'child');assert.equal(f.server().elements.size,before);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,before+1);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
});
// 复制源保存失败时网络无新请求;恢复后保留准备期 source 与稳定身份计划,最终映射数量符合深复制范围。
test('copy source write failure gates network and resume preserves the planned identities',async()=>{
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const sent=f.requests.length;f.broken=true;const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const before=engine.imports.getJobs().get(id);assert.equal(f.requests.length,sent);f.broken=false;await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(before.source.kind,'duplicate-snapshot');assert.equal(before.preparation.allocationIds.length,0);assert.equal(Object.keys(engine.imports.getJobs().get(id).stableIds).length,5);}finally{f.broken=false;await engine.close();}
});
// 只读策略先拒绝 import/copy 准入,即使选择目标不存在也不先读取/存储源或创建任务。
test('read-only policy rejects copy/import admission without storing a source or a running job',async()=>{
const f=fixture(),engine=await f.open({policy:()=>({enabled:false,code:'read_only',targets:[]})});try{await assert.rejects(engine.imports.duplicate([{id:'missing',incarnation:1}]),/read_only/);assert.throws(()=>engine.imports.startNdjson(file([node('one')]),'canvas'),/read_only/);assert.equal(engine.imports.getJobs().size,0);await engine.settle();assert.equal(f.files.size,0);assert.equal(f.requests.length,0);}finally{await engine.close();}
});
// 大复制在已分配 ID 的准备期暂停并重开,复用 planner 游标而不重扫前阶段;源后来改名不影响捕获快照中的副本标题。
test('preparing copy resumes saved planning cursors and allocated identities without repeating earlier phases',async(t)=>{
const f=fixture(),save=f.storage.commit;let durableCuts=0;f.storage.commit=async(...args)=>{durableCuts++;return save(...args);};let engine=await f.open();try{
await seedCopy(engine);assert.equal(engine.editor.transaction(tx=>{for(let i=0;i<1000;i++)tx.dispatch({type:'element.create',element:copyNode(`extra-${i}`,'child')});}).status,'applied');await engine.settle();
@@ -152,29 +212,35 @@ test('preparing copy resumes saved planning cursors and allocated identities wit
}finally{await engine.close();}
});
// 任务已准入但仍 preparing 时关闭,保存任务与快照,重开自动完成五个源键。
test('closing an admitted preparing copy preserves its task and snapshot for automatic restart',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);assert.equal(engine.imports.getJobs().get(id).status,'preparing');await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).committedSourceKeys.length,5);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 准备期取消持久化后重开仍 cancelled,原文档数量不变,不发布任何迟到副本。
test('cancel during preparation is durable and never publishes a copy on reopening',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const size=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 准备时使用的 registry.copy 声明改变,重开后明确暂停为 copy_interpreter_changed,不用新规则静默解释旧快照。
test('a reopened preparation rejects changed registry copy declarations instead of silently reinterpreting the source',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.pause(id);await engine.close();
const changed=createRegistry(builtinComponents().map(component=>component.id==='image'?{...component,copy:{...component.copy,omit:[...component.copy.omit,['customExecution']]}}:component));engine=await f.open({registry:changed});await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'paused');assert.equal(job.error,'copy_interpreter_changed');assert.equal(job.committedSourceKeys.length,0);
}finally{await engine.close();}
});
// 恢复会话 hold 期间 preparing 任务不前进,接纳协调后的状态根后才重新启动并完成。
test('canonical recovery holds a preparing copy and restarts it only after the coordinated root is adopted',async()=>{
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);const recovery=await engine.prepareRecovery(),cut=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,cut);assert.equal(engine.imports.getJobs().get(id).status,'preparing');assert.equal((await recovery.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// frame 端口引用成员、成员归属 child、child 绑定 frame 形成依赖环,必须一次闭合发布;深复制后端口局部 ID/handle 保留、nodeId 映射,重开与 undo/redo 都可恢复。
test('native frame ports and deep copy close ownership/reference cycles atomically, keep local handles and recover every binding',async()=>{
const f=fixture();let engine=await f.open();
const frame={...node('F'),record:{...node('F').record,componentId:'frame',data:{title:'Frame',text:'',ports:[{id:'input',direction:'input',nodeId:'A',handle:'left'}]}}},child={...node('A'),record:{...node('A').record,ownerCanvasId:'S'}},canvas={kind:'canvas',key:'S',record:{id:'S',parent:{canvasId:'$target',entryElementId:'F'}}};
try{
// 观察每次发布的 frame 端口是否已经有目标成员,用 0/2 元素数量检测依赖环的半成品是否泄漏。
const cuts=[],unsubscribe=engine.editor.subscribe((_,snapshot)=>{const rows=[...snapshot.document.elements.values()],frames=rows.filter(row=>row.componentId==='frame');for(const row of frames){const port=row.data.ports[0];assert.ok(snapshot.document.elements.has(port.nodeId),'published a frame without its port target');assert.notEqual(port.nodeId,'A');}cuts.push(rows.length);});
const imported=engine.imports.startNdjson(file([frame,canvas,child]),'canvas');await engine.settle();const first=engine.imports.getJobs().get(imported);assert.equal(first.status,'completed');assert.equal(first.committedSourceKeys.length,3);const F=first.stableIds['element:F'],A=first.stableIds['element:A'];assert.equal(engine.getSnapshot().document.elements.get(F).data.ports[0].nodeId,A);assert.ok(cuts.every(size=>size===0||size===2));unsubscribe();
const copied=await engine.imports.duplicate([{id:F,incarnation:1}]);await engine.settle();const job=engine.imports.getJobs().get(copied);assert.equal(job.status,'completed');assert.equal(job.committedSourceKeys.length,3);const F2=job.stableIds[`element:${F}`],A2=job.stableIds[`element:${A}`],port=engine.getSnapshot().document.elements.get(F2).data.ports[0];assert.deepEqual(port,{id:'input',direction:'input',nodeId:A2,handle:'left'});assert.notEqual(A2,A);assert.equal(f.server().elements.size,4);
@@ -182,6 +248,7 @@ test('native frame ports and deep copy close ownership/reference cycles atomical
}finally{await engine.close();}
});
// 源文件保存一直挂起时允许有限本地前缀,未持久化根差达到背压门槛后停止增长;所有网络请求仍为零,放行才完成。
test('blocked source persistence admits several local batches then applies bounded backpressure and gates every request',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),commit=f.storage.commit;
try{
@@ -191,14 +258,17 @@ test('blocked source persistence admits several local batches then applies bound
}finally{gate.resolve();await engine.close();}
});
// 复制快照字节同长度篡改也会被摘要绑定识别;恢复前无新副本/网络写,原摘要不被替换成篡改源的身份。
test('same-length changes to an admitted copy snapshot are rejected before preparation allocates or publishes content',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const size=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),raw=await f.files.get(job.source.fileId).text(),changed=raw.replace('source','tamper');assert.equal(changed.length,raw.length);await engine.close();const sent=f.requests.length;f.files.set(job.source.fileId,new Blob([changed]));engine=await f.open();await engine.imports.resume(id);await engine.settle();const blocked=engine.imports.getJobs().get(id);assert.equal(blocked.status,'paused');assert.equal(blocked.error,'import_source_changed');assert.equal(blocked.committedSourceKeys.length,0);assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal(f.requests.length,sent);assert.equal(blocked.source.sha256,job.source.sha256);}finally{await engine.close();}
});
// native 尚未解析的尾部被同长度改写,重开验证拒绝并保留原首项,不允许改源后继续沿用旧任务进度。
test('a changed unparsed tail cannot pass native import recovery or alter its preserved prefix',async()=>{
const f=fixture();let engine=await f.open();try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),raw=await f.files.get(job.source.fileId).text(),changed=raw.replaceAll('three','other');assert.equal(changed.length,raw.length);const first=job.stableIds['element:one'];await engine.close();const sent=f.requests.length;f.files.set(job.source.fileId,new Blob([changed]));engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).error,'import_source_changed');assert.equal(engine.getSnapshot().document.elements.size,1);assert.ok(engine.getSnapshot().document.elements.has(first));assert.equal(f.requests.length,sent);}finally{await engine.close();}
});
// 摘要读取挂起期间取消或开启恢复会话,迟到结果不能复活 cancelled,也不能在 held root 发布;释放协调后才可继续。
test('late source digest cannot revive a cancelled job or publish into a held recovery root',async()=>{
for(const cancel of [true,false]){
const f=fixture(),engine=await f.open(),entered=deferred(),gate=deferred(),bytes=new class extends Blob{slice(...args){return new class extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}([super.slice(...args)]);}}([JSON.stringify(node('one'))]);
@@ -206,62 +276,73 @@ test('late source digest cannot revive a cancelled job or publish into a held re
}
});
// 旧未完成源缺乏完整性绑定仍可读取已有检查点,但 resume 明确拒绝补造新身份;原前缀与网络状态保留。
test('legacy unfinished sources remain readable but cannot acquire a new content identity silently',async()=>{
const f=fixture();let engine=await f.open();try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.version=2;delete job.source.sha256;delete job.source.integrityVersion;delete job.source.readProof;delete job.source.integrityError;image.formatVersion=2;const sent=f.requests.length;engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).error,'import_source_integrity_unavailable');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.requests.length,sent);decodeRecovery(f.image(),registry);}finally{await engine.close();}
});
// 把保存的索引源改造为旧完整快照,重算长度/SHA/fingerprint 并去掉 snapshotVersion,使旧准备解释器真正读取旧格式。
async function legacyCopySource(f,job,document){const signal=new AbortController().signal,bytes=await encodeCopySnapshot(document,signal);delete job.source.snapshotVersion;job.source.byteLength=bytes.size;job.source.sha256=await importSourceDigest(bytes,signal);job.sourceFingerprint=`local-file:${job.source.fileId}:${bytes.size}`;f.files.set(job.source.fileId,bytes);}
// 旧 preparation v1 已预留 legacy-frame-id,转换成旧源格式后恢复应沿用该 ID 而不再分配。
test('legacy preparation version 1 still replays its reserved identities instead of allocating replacements',async()=>{
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const sourceDocument=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();await engine.close();const job=f.image().payload.runtime.imports.find(([key])=>key===id)[1];job.preparation.version=1;job.preparation.steps=1;job.preparation.allocationIds=['legacy-frame-id'];delete job.preparation.planner;await legacyCopySource(f,job,sourceDocument);engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:F'],'legacy-frame-id');assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 复制先转换并发布合法前缀,遇未知尾节点保留 partial;只存原快照而非第二份完整 mapped Blob,重开仍可整体撤销副本。
test('copy converts and publishes a prefix before the output tail, preserves it on failure and stores only the original source',async()=>{
const source=createDocument('project',[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}],[copyNode('F','canvas','frame'),...Array.from({length:100},(_,i)=>copyNode(`stream-${i}`,'child')),{...copyNode('opaque','child'),componentId:'unknown-widget'}]),f=fixture(source);let engine=await f.open();
try{let prefix;const release=engine.editor.subscribe((_,snapshot)=>{for(const job of snapshot.runtime.imports.values())if(!prefix&&job.committedSourceKeys.length)prefix={cursor:job.cursor,total:job.source.plan.order.length+job.source.plan.edges.length,status:job.status};});const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.settle();release();const job=engine.imports.getJobs().get(id);assert.ok(prefix.cursor<prefix.total,'entire mapped output was converted before the first publication');assert.equal(prefix.status,'running');assert.equal(job.source.kind,'duplicate-plan');assert.equal(job.parserVersion,2);assert.equal(job.source.plan.emitted,0);assert.equal(job.status,'partial');assert.equal(job.committedSourceKeys.length,102);assert.ok(job.failures.some(failure=>failure.key==='element:opaque'&&failure.code==='copy_schema_unavailable'));assert.equal(engine.getSnapshot().document.elements.size,203);assert.equal(f.files.size,1,'copy stored a second complete mapped file');assert.equal(f.server().elements.size,203);assert.equal(f.errors.length,0);await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,203);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,102);
}finally{await engine.close();}
});
// 旧准备版本 2/3 重开仍分别使用 duplicate-ndjson/parser1 与 duplicate-plan/parser2,不能用当前格式偷换解释器。
test('legacy preparation versions 2 and 3 retain their source interpreters after reopening',async()=>{
for(const version of [2,3]){const f=fixture();let engine=await f.open();try{await seedCopy(engine);const sourceDocument=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();await engine.close();const job=f.image().payload.runtime.imports.find(([key])=>key===id)[1];job.preparation.version=version;await legacyCopySource(f,job,sourceDocument);engine=await f.open();await engine.imports.resume(id);await engine.settle();const done=engine.imports.getJobs().get(id);assert.equal(done.status,'completed');assert.equal(done.source.kind,version===2?'duplicate-ndjson':'duplicate-plan');assert.equal(done.parserVersion,version===2?1:2);assert.equal(f.errors.length,0);}finally{await engine.close();}}
});
// 恢复复制的 16 KiB 源解码挂起时,cancel 保留旧前缀;恢复会话 hold 则阻止后续发布,接纳后再完成。
test('late planned-source decoding respects cancellation and canonical recovery holds',async()=>{
for(const cancel of [true,false]){
const f=fixture();let engine=await f.open();const entered=deferred(),gate=deferred();try{
await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),size=engine.getSnapshot().document.elements.size;await engine.close();
class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
class DelayedSource extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return end-start===16*1024?new DelayedChunk([part]):part;}}
/* arrayBuffer 真正返回前发出 entered 并等待 gate,以制造取消/恢复期间的迟到正文。 */ class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
/* 仅拦住 16 KiB 源解码窗口,其他读取保留真实 Blob 行为。 */ class DelayedSource extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return end-start===16*1024?new DelayedChunk([part]):part;}}
f.files.set(before.source.fileId,new DelayedSource([f.files.get(before.source.fileId)]));engine=await f.open();const resuming=engine.imports.resume(id);await entered.promise;
if(cancel){await engine.imports.cancel(id);gate.resolve();await resuming;await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,size);}else{const preparing=engine.prepareRecovery();gate.resolve();await resuming;const session=await preparing,revision=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal((await session.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');}assert.equal(f.errors.length,0);
}finally{gate.resolve();await engine.close();}
}
});
// 索引已可用时,晚到大正文块挂起不阻塞前缀副本可见;放行后才读取剩余内容并完成全部副本。
test('indexed copy displays a prefix while a later raw payload read is still blocked',async()=>{
const source=createDocument('project',[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}],[copyNode('F','canvas','frame'),...Array.from({length:100},(_,i)=>({...copyNode(`lazy-${i}`,'child'),data:{title:`lazy-${i}`,text:'x'.repeat(3000)}}))]),f=fixture(source);let engine=await f.open();const entered=deferred(),gate=deferred();try{
const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id);await engine.close();const bytes=f.files.get(before.source.fileId),rows=(await bytes.text()).trimEnd().split('\n');let payload=0;
for(const text of rows){const row=JSON.parse(text);payload+=new TextEncoder().encode(text+'\n').byteLength;if(row.kind==='payload')break;}
class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
class DelayedPayload extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return start>=payload+64*1024&&(start-payload)%(64*1024)===0?new DelayedChunk([part]):part;}}
/* arrayBuffer 真正返回前发出 entered 并等待 gate,以制造取消/恢复期间的迟到正文。 */ class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
/* 仅从正文区第二个对齐 64 KiB 窗口起挂起;头部、索引和首个原始正文窗口仍可读。 */ class DelayedPayload extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return start>=payload+64*1024&&(start-payload)%(64*1024)===0?new DelayedChunk([part]):part;}}
f.files.set(before.source.fileId,new DelayedPayload([bytes]));engine=await f.open();await engine.imports.resume(id);await entered.promise;const cut=engine.getSnapshot(),job=cut.runtime.imports.get(id);assert.equal(job.source.snapshotVersion,2);assert.equal(job.status,'running');assert.ok(job.committedSourceKeys.length>0&&job.committedSourceKeys.length<102);assert.ok(cut.document.elements.size>source.elements.size&&cut.document.elements.size<202,'raw tail read gated the entire copy');gate.resolve();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,202);assert.equal(f.errors.length,0);
}finally{gate.resolve();await engine.close();}
});
// 两个项目共享摘要池,关闭一个只取消它的请求,不关闭池或影响另一个项目;被关闭项目重开后仍可继续。
test('closing a project aborts its injected digest without closing another project sharing the pool',async()=>{
const {createDigestPool}=await import('../../src/lib/canvas-runtime/digest-pool.ts'),entered=deferred();let first=true;const pool=createDigestPool({concurrency:1,execute:async(bytes,signal)=>{if(first){first=false;entered.resolve();await new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('stopped')),{once:true}));}return importSourceDigest(bytes,signal);}}),a=fixture(),b=fixture();let ea=await a.open({digests:pool}),eb=await b.open({digests:pool});
try{const aid=ea.imports.startNdjson(file([node('A')]),'canvas');await entered.promise;const bid=eb.imports.startNdjson(file([node('B')]),'canvas');await ea.close();await eb.settle();assert.equal(eb.imports.getJobs().get(bid).status,'completed');assert.equal(eb.getSnapshot().document.elements.size,1);assert.equal(a.errors.length,0);assert.equal(b.errors.length,0);ea=await a.open();await ea.settle();assert.equal(ea.imports.getJobs().get(aid).status,'completed');assert.equal(ea.getSnapshot().document.elements.size,1);}finally{await ea.close();await eb.close();pool.close();}
});
// 只手动推进回收墙上时间,真实定时器仍服务于调度;用于精确检查源文件退休宽限期。
const retentionClock=()=>{let now=1000;return{now:()=>now,advance:ms=>{now+=ms;},setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};};
// 任务本地终态后源文件可标记/宽限期回收,即使文档请求还未 ACK;内容、history 和冻结请求体保持,重开 undo/redo 不依赖已退休源。
test('terminal import source collection preserves every imported record, undo unit and pending request across source-v2 reopening',async()=>{
const f=fixture(),clock=retentionClock(),gate=deferred();f.gate=gate;let engine=await f.open({clock});try{
const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');await engine.persist();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId,document=engine.getSnapshot().document,history=engine.getSnapshot().history;
const marked=await engine.collectImportSources({retentionMs:100});assert.deepEqual(marked.markedIds,[fileId]);assert.deepEqual(marked.purgedIds,[]);assert.ok(f.files.has(fileId));assert.equal(f.image().formatVersion,17);clock.advance(100);
const result=await engine.collectImportSources({retentionMs:100});assert.deepEqual(result.purgedIds,[fileId]);assert.equal(f.files.has(fileId),false);assert.equal(engine.getSnapshot().document,document);assert.equal(engine.getSnapshot().history,history);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].some(record=>record.phase!=='resolved'));
// 保存所有冻结请求的 ID/body;源文件回收不应重写它们,后续重开逐个比较。
const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()].map(request=>[request.id,request.body]);decodeRecovery(f.image(),registry);for(const formatVersion of [1,2,3])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);
gate.resolve();await engine.settle();await engine.close();engine=await f.open({clock});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,2);for(const [id,body]of frozen)assert.equal(engine.getSnapshot().runtime.ledger.requests.get(id).body,body);await assert.rejects(engine.imports.openSource(id),/import_source_retired/);
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.size,2);assert.equal(f.errors.length,0);
@@ -269,6 +350,7 @@ test('terminal import source collection preserves every imported record, undo un
}finally{gate.resolve();await engine.close();}
});
// paused 源仍需保留并可 openSource;partial/cancelled 源可回收,重开后不能借 resume 复活内容或重复失败。
test('paused source remains recoverable while partial and cancelled sources become collectible without reviving content',async()=>{
const f=fixture();let engine=await f.open();try{
const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const source=engine.imports.getJobs().get(id).source;assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);assert.ok(f.files.has(source.fileId));const handle=await engine.imports.openSource(id);assert.equal(await handle.bytes.text(),await file([node('one'),node('two'),node('three')]).text());handle.release();
@@ -276,6 +358,7 @@ test('paused source remains recoverable while partial and cancelled sources beco
}finally{await engine.close();}
});
// 打开终态源前先 pin 并清除 soft marker,异步读取期间不可删;最后 release 后重新开始完整宽限期。
test('opening a terminal source pins before I/O, clears its soft marker and restarts the full retention interval',async()=>{
const f=fixture(),clock=retentionClock(),engine=await f.open({clock}),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;await engine.collectImportSources({retentionMs:100});clock.advance(200);f.storage.readFile=async(...args)=>{if(args[1]===fileId){entered.resolve();await gate.promise;}return read(...args);};
@@ -283,6 +366,7 @@ test('opening a terminal source pins before I/O, clears its soft marker and rest
}finally{gate.resolve();await engine.close();}
});
// 源文件删除落盘失败保留字节与 reservation,阻止 openSource 及非法恢复 running;persist 补成同一 purge cut 后才退休,内容仍可编辑。
test('failed source deletion retains byte identity and blocks late reads until the same purge cut becomes durable',async()=>{
const f=fixture(),engine=await f.open(),commit=f.storage.commit;let fail=false;try{
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('source disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectImportSources({retentionMs:0}),/source disk failure/);assert.ok(f.files.has(fileId));await assert.rejects(engine.imports.openSource(id),/resource_collection_in_progress/);const root=engine.getSnapshot(),job=root.runtime.imports.get(id);assert.equal(engine.editor.updateRuntime(previous=>{const jobs=new Map(previous.imports);jobs.set(id,{...job,status:'running',source:{...job.source,retirement:undefined}});return{...previous,imports:jobs};}).status,'rejected');assert.equal(engine.getSnapshot(),root);
@@ -290,6 +374,7 @@ test('failed source deletion retains byte identity and blocks late reads until t
}finally{fail=false;await engine.close();}
});
// 旧快照转 NDJSON 时归档 previousFiles,旧代可单独 pin/回收;当前代完成后也可回收,元素引用和历史仍可恢复。
test('legacy snapshot conversion records its previous file, then collects both generations without losing copied references',async()=>{
const f=fixture();let engine=await f.open();try{
await seedCopy(engine);const captured=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1],bytes=await encodeCopySnapshot(captured,new AbortController().signal),oldFile=job.source.fileId;f.files.set(oldFile,bytes);job.source.byteLength=bytes.size;job.source.sha256=await importSourceDigest(bytes);job.sourceFingerprint=`local-file:${oldFile}:${bytes.size}`;delete job.source.snapshotVersion;job.preparation={version:1,registrySignature:job.preparation.registrySignature,selection:[{id:'F',incarnation:1}],worldOffset:{x:32,y:32},allocationIds:[],steps:0};decodeRecovery(image,registry);
@@ -298,6 +383,7 @@ test('legacy snapshot conversion records its previous file, then collects both g
}finally{await engine.close();}
});
// openSource 验证当前文件与绑定摘要;篡改拒绝并释放 pin,读取期间关闭项目则迟到结果报 engine_closing。
test('source read handles verify bound bytes and never publish a result after project close',async()=>{
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId,bytes=f.files.get(fileId),text=await bytes.text();f.files.set(fileId,new Blob([text.replace('one','two')]));await assert.rejects(engine.imports.openSource(id),/import_source_changed/);assert.equal(engine.getResourcePins().pins.has(fileId),false);f.files.set(fileId,bytes);
@@ -305,32 +391,41 @@ test('source read handles verify bound bytes and never publish a result after pr
}finally{gate.resolve();await engine.close();}
});
// JSON 数组任务保存原字节及字节游标,暂停/编辑/重开再续跑保持原 ID 和用户标题,证明型 image 不允许格式降级。
test('JSON array import persists original bytes and byte cursor, resumes after reopen, and preserves edits without duplicating IDs',async()=>{const f=fixture();let engine=await f.open();try{const bytes=new Blob([JSON.stringify([node('json-one'),node('json-two'),node('json-three')])]),id=engine.imports.startJsonArray(bytes,'canvas');await until(engine,s=>s.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:json-one'];assert.equal(job.parserVersion,3);assert.equal(job.source.kind,'native-json-array');assert.ok(job.cursor>0);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:13},registry),/unsupported_recovery_version/);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:16},registry),/unsupported_recovery_version/);engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'preserved edit'}});await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'preserved edit');assert.equal(f.server().elements.size,3);assert.equal(f.errors.length,0);}finally{await engine.close();}});
// 数组尾逗号使任务 partial,只保留解析器已接受的合法前缀;即使后端 gate 未放行,本地也能展示该前缀和显式错误。
test('JSON array malformed tail keeps its visible prefix and an explicit failure while backend waits',async()=>{const f=fixture(),engine=await f.open(),gate=deferred();f.gate=gate;try{const id=engine.imports.startJsonArray(new Blob(['['+JSON.stringify(node('json-first'))+','+JSON.stringify(node('json-bad'))+',]']),'canvas');await until(engine,s=>s.runtime.imports.get(id).status==='partial');const job=engine.imports.getJobs().get(id);assert.equal(job.committedSourceKeys.length,1);assert.equal(job.failures.at(-1).code,'invalid_json_array');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,0);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,1);decodeRecovery(f.image(),registry);}finally{gate.resolve();await engine.close();}});
// 导出文档导入指定目标根,生成新元素/子 canvas ID,重建 owner 与上级连线;旧源墓碑不移植为目标身份,重开保留四元素。
test('exported document imports into a chosen root with fresh IDs, child ownership and upper connections then reopens',async()=>{const f=fixture();let engine=await f.open();try{const frame={...node('frame').record,componentId:'frame',ownerCanvasId:'source-root'},child={...node('child').record,ownerCanvasId:'source-child'},outside={...node('outside').record,ownerCanvasId:'source-root'},edge={id:'incoming',incarnation:1,ownerCanvasId:'source-root',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'outside'},target:{elementId:'frame'},data:{},path:null},input={documentFormatVersion:1,projectId:'source-root',canvases:[{id:'source-root',parent:null},{id:'source-child',parent:{canvasId:'source-root',entryElementId:'frame'}}],elements:[frame,child,outside,edge],tombstones:{old:2}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'completed',JSON.stringify(job.failures));const doc=engine.getSnapshot().document,frameId=job.stableIds['element:frame'],childId=job.stableIds['canvas:source-child'];assert.equal(doc.canvases.size,2);assert.equal(doc.canvases.get(childId).parent.entryElementId,frameId);assert.equal(doc.elements.get(job.stableIds['element:child']).ownerCanvasId,childId);assert.equal(doc.elements.get(job.stableIds['element:incoming']).ownerCanvasId,'canvas');assert.equal(doc.elements.get(job.stableIds['element:incoming']).target.elementId,frameId);assert.equal(doc.tombstones.has('old'),false);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,4);assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(f.errors.length,0);}finally{await engine.close();}});
// 无资源授权端口时图片引用报 import_resource_authorization_required,独立文本仍进入文档并同步。
test('document resource references report an authorization failure while independent text imports',async()=>{const f=fixture(),engine=await f.open();try{const image={...node('image').record,componentId:'image',ownerCanvasId:'source',data:{title:'image',assetId:'untrusted',src:'https://assets.test/untrusted'}},text={...node('text').record,ownerCanvasId:'source'},input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[image,text],tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.ok(job.failures.some(row=>row.code==='import_resource_authorization_required'));assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);}finally{await engine.close();}});
// 导出文档使用记录序号恢复,首项后暂停编辑,重开继续不覆盖手动标题或重复元素。
test('paused exported document resumes ordinal progress after reopen without overwriting an edited prefix',async()=>{const f=fixture();let engine=await f.open();try{const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:['d-one','d-two','d-three'].map(id=>({...node(id).record,ownerCanvasId:'source'})),tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await until(engine,s=>s.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:d-one'];assert.equal(job.cursor,1);engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'local edit'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'local edit');assert.equal(f.errors.length,0);}finally{await engine.close();}});
// parser 5 尾部扫描挂起时首项已可编辑;暂停/重开继续同解释器和稳定 ID,保留编辑,错误 image 版本拒绝。
test('streaming exported document publishes an editable prefix before the parser tail and resumes parser 5 after reopen',async()=>{
const f=fixture(),gate=deferred(),entered=deferred();let block=true,engine=await f.open();
class Source extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||!block)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
/* 文档扫描从 32 KiB 起的 16 KiB 窗口可挂起;前项仍可先解析和发布。 */ class Source extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||!block)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
try{
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('stream-first').record,ownerCanvasId:'source'},{...node('stream-tail').record,ownerCanvasId:'source',data:{title:'tail',text:'x'.repeat(100000)}}],tombstones:{}},id=engine.imports.startDocument(new Source([JSON.stringify(input)]),'canvas');
await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.includes('element:stream-first'));
const job=engine.imports.getJobs().get(id),first=job.stableIds['element:stream-first'];assert.equal(job.parserVersion,5);assert.equal(engine.getSnapshot().document.elements.size,1);
assert.equal(engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'edited while tail blocked'}}).status,'applied');
// 先请求暂停再释放底层扫描,验证迟到尾部不会绕过暂停;随后持久化 parser/proof 以供重开。
const paused=engine.imports.pause(id);block=false;gate.resolve();await paused;await engine.settle();assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:15},registry),/unsupported_recovery_version/);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:18},registry),/unsupported_recovery_version/);
await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'edited while tail blocked');assert.equal(f.server().elements.size,2);assert.equal(f.errors.length,0);
}finally{block=false;gate.resolve();await engine.close();}
});
// 文档 footer 墓碑非法时已导入元素仍可见并同步;partial 及错误诊断持久化,重开保留该成功前缀。
test('bad document footer produces durable partial success rather than discarding imported content',async()=>{
const f=fixture();let engine=await f.open();try{
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('legal').record,ownerCanvasId:'source'}],tombstones:{broken:0}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.ok(job.failures.some(row=>row.code==='invalid_document_import'));assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// 显式构造旧格式 15/parser4/integrity1 检查点,恢复沿用原解释器及 ID,不能暗升成 parser5 或改变 image 格式。
test('legacy format 15 parser 4 document jobs keep their original interpreter and stable IDs when resumed',async()=>{
const f=fixture();let engine=await f.open();try{
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:['legacy-one','legacy-two'].map(id=>({...node(id).record,ownerCanvasId:'source'})),tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:legacy-one'];await engine.close();
@@ -339,9 +434,10 @@ test('legacy format 15 parser 4 document jobs keep their original interpreter an
}finally{await engine.close();}
});
// 临时正文 I/O 错误暂停在原 ordinal,eof=false 且无坏记录诊断;恢复端口后继续尾项,保留首项手动编辑。
test('temporary document byte I/O pauses without consuming a bad-tail ordinal and resumes with edited prefix intact',async()=>{
const f=fixture(),engine=await f.open();let fail=true;
class Flaky extends Blob{slice(start=0,end=this.size,...rest){if(fail&&end-start===16384&&start>=16384)throw Error('temporary source I/O');return super.slice(start,end,...rest);}}
/* 仅让后续 16 KiB 扫描窗口抛临时 I/O;切换 fail 后同一 Blob 可读,不改变字节身份。 */ class Flaky extends Blob{slice(start=0,end=this.size,...rest){if(fail&&end-start===16384&&start>=16384)throw Error('temporary source I/O');return super.slice(start,end,...rest);}}
try{
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('io-first').record,ownerCanvasId:'source'},{...node('io-tail').record,ownerCanvasId:'source',data:{title:'tail',text:'x'.repeat(100000)}}]},id=engine.imports.startDocument(new Flaky([JSON.stringify(input)]),'canvas');
await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const job=engine.imports.getJobs().get(id),first=job.stableIds['element:io-first'];assert.equal(job.error,'import_source_read_failed');assert.equal(job.eof,false);assert.equal(job.cursor,1);assert.equal(job.failures.length,0);assert.equal(job.committedSourceKeys.length,1);
@@ -349,52 +445,63 @@ test('temporary document byte I/O pauses without consuming a bad-tail ordinal an
}finally{fail=false;await engine.close();}
});
// 完整 SHA 尚未返回时读取证明允许先发布/编辑前缀;即使 persist 本地内容,网络仍不发,待摘要成功后才同步用户编辑。
test('native import publishes and edits a proof-backed prefix before full SHA, with every request waiting for its durable seal',async()=>{
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({digests:{async digest(bytes,signal){entered.resolve();await gate.promise;return importSourceDigest(bytes,signal);}}});
try{const id=engine.imports.startNdjson(file([node('early-one'),node('early-two')]),'canvas');await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);const job=engine.imports.getJobs().get(id),first=job.stableIds['element:early-one'];assert.equal(job.source.integrityVersion,2);assert.ok(job.source.readProof.blocks.length);assert.equal(job.source.sha256,undefined);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'edit before hash'}}).status,'applied');await engine.persist();assert.equal(f.requests.length,0);assert.equal(f.server().elements.size,0);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);gate.resolve();await engine.settle();assert.ok(engine.imports.getJobs().get(id).source.sha256);assert.equal(f.server().elements.get(first).data.title,'edit before hash');assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
});
// 内存中已有完整 SHA 仍不足以放行网络;必须等含该 seal 的恢复 cut 真正落盘,再允许相关文档请求。
test('a verified SHA still cannot release pending import writes before the seal cut is durable',async()=>{
const f=fixture(),hash=deferred(),save=deferred(),saving=deferred(),commit=f.storage.commit,engine=await f.open({digests:{async digest(bytes,signal){await hash.promise;return importSourceDigest(bytes,signal);}}});
try{const id=engine.imports.startNdjson(file([node('seal-one')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length===1);await engine.persist();f.storage.commit=async(owner,batch)=>{if(batch.image.payload.runtime.imports?.some(([key,job])=>key===id&&job.source.sha256)){saving.resolve();await save.promise;}return commit(owner,batch);};hash.resolve();await saving.promise;assert.ok(engine.imports.getJobs().get(id).source.sha256);assert.equal(f.requests.length,0);save.resolve();await engine.settle();assert.equal(f.server().elements.size,1);assert.equal(f.errors.length,0);}finally{hash.resolve();save.resolve();await engine.close();}
});
// 未封存前 pause/cancel 保留可见前缀、继续挡网络且暂不可回收源;校验后只同步已接受内容,不改变暂停/取消终态。
test('pause and cancel retain the unverified prefix and never turn off the source gate early',async()=>{
for(const cancel of [false,true]){const f=fixture(),gate=deferred(),engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;return importSourceDigest(bytes,signal);}}});try{const id=engine.imports.startNdjson(file([node('stop-one'),node('stop-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);if(cancel)await engine.imports.cancel(id);else await engine.imports.pause(id);await engine.persist();assert.equal(f.requests.length,0);assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);const count=engine.getSnapshot().document.elements.size;gate.resolve();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,cancel?'cancelled':'paused');assert.equal(f.server().elements.size,count);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}}
});
// 源验证失败保留本地内容和稳定 ID,阻止发送;修复 digest 端口再 resume,只补验证与剩余导入,不重复身份。
test('a source verification failure retains local content, blocks sending and can retry without duplicate identities',async()=>{
const f=fixture(),gate=deferred();let fail=true;const engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;if(fail)throw Error('digest unavailable');return importSourceDigest(bytes,signal);}}});
try{const id=engine.imports.startNdjson(file([node('retry-one'),node('retry-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);gate.resolve();await engine.settle();const job=engine.imports.getJobs().get(id),ids={...job.stableIds};assert.equal(job.source.integrityError,'import_source_verification_failed');assert.equal(f.requests.length,0);assert.ok(engine.getSnapshot().document.elements.size>0);fail=false;await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');for(const[key,id]of Object.entries(ids))assert.equal(engine.imports.getJobs().get(job.id).stableIds[key],id);assert.equal(f.server().elements.size,2);assert.equal(engine.imports.getJobs().get(id).source.integrityError,undefined);}finally{fail=false;gate.resolve();await engine.close();}
});
// 未 seal 的持久化检查点重开保留原前缀/ID,仍挡发送;重新验证并续跑后才同步完整内容。
test('an unsealed durable checkpoint reopens with the same prefix and keeps sends blocked until reverified',async()=>{
const f=fixture(),gate=deferred(),entered=deferred();let engine=await f.open({digests:{digest(bytes,signal){entered.resolve();return new Promise((resolve,reject)=>{const abort=()=>reject(Error('aborted'));signal.addEventListener('abort',abort,{once:true});gate.promise.then(()=>{signal.removeEventListener('abort',abort);importSourceDigest(bytes,signal).then(resolve,reject);});});}}});
try{const id=engine.imports.startNdjson(file([node('reopen-one'),node('reopen-two')]),'canvas');await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.pause(id);await engine.persist();const ids={...engine.imports.getJobs().get(id).stableIds};assert.equal(f.image().formatVersion,17);await engine.close();const nextGate=deferred();engine=await f.open({digests:{async digest(bytes,signal){await nextGate.promise;return importSourceDigest(bytes,signal);}}});await engine.persist();assert.equal(f.requests.length,0);assert.deepEqual(engine.imports.getJobs().get(id).stableIds,ids);nextGate.resolve();await engine.imports.resume(id);await engine.settle();assert.equal(f.server().elements.size,2);for(const[key,id]of Object.entries(ids))assert.equal(engine.imports.getJobs().get(engine.imports.getJobs().keys().next().value).stableIds[key],id);}finally{gate.resolve();await engine.close();}
});
// 恢复会话取消旧 verifier 后,接纳基线需重新启动验证;不能留下无错误但永远不放行网络的静默 hold。
test('canonical recovery restarts an aborted unsealed-source verifier instead of leaving a silent send hold',async()=>{
const f=fixture(),gate=deferred();let calls=0;const engine=await f.open({digests:{digest(bytes,signal){calls++;return new Promise((resolve,reject)=>{const abort=()=>reject(Error('aborted'));signal.addEventListener('abort',abort,{once:true});gate.promise.then(()=>{signal.removeEventListener('abort',abort);importSourceDigest(bytes,signal).then(resolve,reject);});});}}});
try{const id=engine.imports.startNdjson(file([node('hold-one'),node('hold-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);const session=await engine.prepareRecovery();assert.equal((await session.apply()).status,'applied');for(let i=0;i<100&&calls<2;i++)await new Promise(resolve=>setTimeout(resolve,1));assert.ok(calls>=2,'source verifier did not restart after recovery released');assert.equal(f.requests.length,0);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,2);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
});
// 未 seal 源重开先重验已记录块,篡改即阻塞且不再分配 ID;恢复原字节后才允许重试和同步。
test('unsealed recovery checks recorded bytes before allocating more IDs or releasing writes',async()=>{
const f=fixture(),gate=deferred();let engine=await f.open({digests:{digest(bytes,signal){return new Promise((resolve,reject)=>{signal.addEventListener('abort',()=>reject(Error('aborted')),{once:true});gate.promise.then(()=>importSourceDigest(bytes,signal).then(resolve,reject));});}}});
try{const id=engine.imports.startNdjson(file([node('proof-one'),node('proof-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.pause(id);await engine.persist();const before=engine.imports.getJobs().get(id),ids={...before.stableIds};await engine.close();const bytes=f.files.get(before.source.fileId),text=await bytes.text();f.files.set(before.source.fileId,new Blob([text.replace('proof-two','wrong-two')]));engine=await f.open();await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.source.integrityError,'import_source_changed');assert.equal(f.requests.length,0);assert.deepEqual(job.stableIds,ids);assert.ok(engine.getSnapshot().document.elements.size>0);f.files.set(before.source.fileId,bytes);await engine.imports.resume(id);await engine.settle();assert.equal(f.server().elements.size,2);}finally{gate.resolve();await engine.close();}
});
// 源摘要、读取证明及完整性版本由引擎拥有,公共 updateRuntime 不能伪造证据或清空 committed 进度;拒绝后 root 不变。
test('source evidence is engine-owned and cannot be forged through public runtime updates',async()=>{
const f=fixture(),engine=await f.open();try{const id=engine.imports.startNdjson(file([node('owned-proof')]),'canvas');await engine.settle();const before=engine.getSnapshot(),job=engine.imports.getJobs().get(id);for(const source of [{...job.source,sha256:'0'.repeat(64)},{...job.source,readProof:{...job.source.readProof,blocks:[]}},{...job.source,integrityVersion:1,readProof:undefined}]){const result=engine.editor.updateRuntime(runtime=>({...runtime,imports:new Map(runtime.imports).set(id,{...job,source})}));assert.equal(result.status,'rejected');assert.equal(engine.getSnapshot(),before);}const forged=engine.editor.updateRuntime(runtime=>({...runtime,imports:new Map(runtime.imports).set(id,{...job,status:'cancelled',committedSourceKeys:[]})}));assert.equal(forged.status,'rejected');assert.equal(engine.getSnapshot(),before);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 取消后已接受前缀仍需验证:engine.retry 可补完失败校验并同步该前缀,但保持 cancelled 且不重新开始插入。
test('retry verifies a cancelled accepted prefix without restarting insertion',async()=>{
const f=fixture(),gate=deferred();let fail=true;const engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;if(fail)throw Error('digest unavailable');return importSourceDigest(bytes,signal);}}});try{const id=engine.imports.startNdjson(file([node('retry-cancel-one'),node('retry-cancel-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.cancel(id);gate.resolve();await engine.settle();const before=engine.imports.getJobs().get(id),count=engine.getSnapshot().document.elements.size;assert.ok(before.source.integrityError);assert.equal(f.requests.length,0);fail=false;await engine.retry();await engine.settle();const after=engine.imports.getJobs().get(id);assert.equal(after.status,'cancelled');assert.equal(after.source.integrityError,undefined);assert.ok(after.source.sha256);assert.deepEqual(after.stableIds,before.stableIds);assert.equal(engine.getSnapshot().document.elements.size,count);assert.equal(f.server().elements.size,count);}finally{gate.resolve();await engine.close();}
});
// 旧数组格式 14 和旧 streaming document 格式 16 的 integrity1 状态仍按 SHA-first 语义读取,分别保留 parser3/5。
test('legacy native array 14 and streaming document 16 retain their SHA-first interpreters',async()=>{
for(const format of ['array','document']){const f=fixture();let engine=await f.open();try{const bytes=format==='array'?new Blob([JSON.stringify([node('legacy-native')])]):new Blob([JSON.stringify({documentFormatVersion:1,projectId:'legacy-source',canvases:[{id:'legacy-source',parent:null}],elements:[{...node('legacy-native').record,ownerCanvasId:'legacy-source'}],tombstones:{}})]),id=format==='array'?engine.imports.startJsonArray(bytes,'canvas'):engine.imports.startDocument(bytes,'canvas');await engine.settle();await engine.close();const image=structuredClone(f.image());image.formatVersion=format==='array'?14:16;const job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.source.integrityVersion=1;delete job.source.readProof;delete job.source.integrityError;const decoded=decodeRecovery(image,registry);assert.equal(decoded.runtime.imports.get(id).source.integrityVersion,1);f.storage.recover=async()=>structuredClone(image);engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).parserVersion,format==='array'?3:5);assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}}
});
// 千项真实引擎准备/ready 源在发布监听中异步封存,后续推进不改变捕获字节;准备编码实际让出,重开保留暂停和映射。
test('engine preparation encodes in slices and the ready source retains checkpoint bytes and mappings on reopen', {timeout:20000},async()=>{
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const rows=Array.from({length:1000},(_,i)=>({...node('live-checkpoint-'+i).record,incarnation:1,ownerCanvasId:'canvas'}));
@@ -414,6 +521,7 @@ test('engine preparation encodes in slices and the ready source retains checkpoi
const id=await engine.imports.duplicate(rows.map(row=>({id:row.id,incarnation:1})));
await until(engine,snapshot=>snapshot.runtime.imports.get(id)?.source?.kind==='duplicate-plan');
await engine.imports.pause(id);unsubscribe();
// 等待发布监听里启动的封存全部完成,检查准备与源两个阶段都被捕获,且编码期间后续变更未改写旧值。
const results=await Promise.all(captured);assert.deepEqual([...seen].sort(),['preparation','source']);
for(const result of results){assert.equal(result.error,undefined);if(result.phase==='preparation')assert.ok(result.yields>0,'preparation checkpoint was synchronously normalized');assert.equal(JSON.stringify(result.image.payload),result.before);}
const ids=engine.imports.getJobs().get(id).stableIds;gate.resolve();await engine.close();engine=await f.open();
+50
View File
@@ -1,22 +1,40 @@
/**
* 独立导入 runner 的解析、依赖分组、发布和持久化状态机测试。每个已提交前缀可见,坏记录及取消不默认回滚全部导入。
* 依赖循环作为强连通组原子提交;稳定 ID、待处理定位、源身份和 generation 支持恢复并隔离迟到异步准备。
* fixture 用内存文档与克隆 cut 验证端口调用顺序;引擎/真实存储集成另有测试,不由此模拟持久层事务保证。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,applyChanges,validateDocument,createRegistry,builtinComponents,validJson,fault} from '../../src/lib/canvas-core/index.ts';
import {createImportJob,validateImportJob,createNdjsonImportSource,createImportRunner} from '../../src/lib/canvas-runtime/imports.ts';
// 内置定义及空单 canvas 文档,供每个 fixture 从相同不可变基线开始。
const registry=createRegistry(builtinComponents()),base=createDocument('project',[{id:'canvas',parent:null}],[]);
// 手动 Promise 屏障用于暂停源读取、persist 或 beforeCommit,定位取消/恢复的边界。
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
// 有限推进微任务,检查预算尚未放行时确实没有提前发布内容。
const ticks=async()=>{for(let i=0;i<30;i++)await Promise.resolve();};
// 把对象或故意损坏的原始行拼成 NDJSON Blob,允许同一输入同时含合法和错误记录。
const bytes=records=>new Blob([records.map(record=>typeof record==='string'?record:JSON.stringify(record)).join('\n')]);
// 组装源、当前 document/job 状态根与 runner 端口,options 可覆写端口或指定恢复状态。
function fixture(records,options={}){
const source=options.source??createNdjsonImportSource({bytes:bytes(records),fingerprint:'immutable-source'}),calls=[],errors=[],saved=[],commits=[];let n=0,time=0,root=options.root??{document:base,job:createImportJob({id:'import',projectId:'project',sourceFingerprint:source.fingerprint,parserVersion:1})};
// root getter/setter 暴露测试当前状态;真实修改通过 publish/commit,少数用例显式替换它来模拟外部编辑或换代。
const f={source,calls,errors,saved,commits,get root(){return root;},set root(value){root=value;},ports:{source,getJob:()=>root.job,createId:()=>`new-${++n}`,now:()=>time,
// 以 job.generation 做条件发布,只替换 job 元数据;文档变化必须经 commit 入口。
publish(job,expected){assert.equal(root.job.generation,expected);root={...root,job};},
// 最小解析转换:校验输入 ID、reserve 稳定 ID、保留 source-key 依赖,再生成节点创建;bad 标记注入非法宽度。
prepare(line,job,reserve){time+=2;const input=line.value;if(!input||typeof input!=='object'||!input.id)throw fault('invalid_record');const id=reserve(input.id);return{key:input.id,dependencies:input.dependencies??[],changes:[{kind:'element',id,before:null,after:{id,incarnation:1,structure:'node',ownerCanvasId:'canvas',componentId:'text',dataVersion:1,data:{title:input.title??input.id,text:''},geometry:{x:0,y:0,width:input.bad?-1:100,height:100,rotation:0}}}]};},
// 在私有 document 应用整组变化并校验,成功才把 document/job 一起替换;拒绝时原 root 必须不变。
commit(units,job,expected){time+=2;assert.equal(root.job.generation,expected);calls.push(units.map(unit=>unit.key));const before=root;try{const document=applyChanges(root.document,units.flatMap(unit=>unit.changes));validateDocument(document,registry);root={document,job};commits.push(root);return{status:'applied',revision:commits.length,operationIds:[]};}catch(error){assert.equal(root,before);return{status:'rejected',code:error.code??'invalid',targets:[]};}},
// 校验 job 可序列化且结构合法,再克隆 job 与文档记录为恢复 cut;saved 用于后续重开对照。
async persist(){validateImportJob(root.job);assert.equal(validJson(root.job),true);saved.push(structuredClone({job:root.job,document:{projectId:root.document.projectId,canvases:[...root.document.canvases.values()],elements:[...root.document.elements.values()],tombstones:[...root.document.tombstones]}}));},
// 错误上报收集到 errors,yieldTask 计数协作调度;调用者 ports 最后覆盖以注入背压和故障。
onError:error=>errors.push(error),yieldTask:async()=>{f.yields++;},...options.ports},yields:0};
// 所有端口装好后才创建 runner,避免调用未就绪 fixture;返回端口引用供各用例精确注入故障。
f.runner=createImportRunner(f.ports);return f;
}
// UTF-8 按 3 字节块解析并以字节游标重读;坏 JSON/超大行只报各自行错误,后续正常行保留,输入 options 改写不换源。
test('NDJSON parses bounded UTF-8 chunks, seeks by byte cursor and reports malformed/oversized records without losing the next line',async()=>{
const source=createNdjsonImportSource({bytes:bytes([{id:'中文 😀'},'{bad}','x'.repeat(30),{id:'ok'}]),fingerprint:'file-1',maxRecordBytes:24,chunkBytes:3}),signal=new AbortController().signal;
let cursor=0;const rows=[];for(;;){const row=await source.read(cursor,signal);if(!row)break;rows.push(row);cursor=row.nextCursor;}
@@ -24,6 +42,7 @@ test('NDJSON parses bounded UTF-8 chunks, seeks by byte cursor and reports malfo
const input={bytes:bytes([{id:'old'}]),fingerprint:'fixed'},owned=createNdjsonImportSource(input);input.bytes=bytes([{id:'replacement'}]);assert.equal((await owned.read(0,signal)).value.id,'old');
});
// 第二项读取挂起时第一项已可见且已登记 committed key;取消保留该前缀,不能等 EOF 才显示或把已提交内容回滚。
test('records become visible before EOF, task yields real scheduling points and cancellation preserves the committed prefix',async()=>{
const gate=deferred(),entered=deferred(),raw=bytes([{id:'first'},{id:'second'}]),initial=createNdjsonImportSource({bytes:raw,fingerprint:'immutable-source'});let firstEnd;
firstEnd=(await initial.read(0,new AbortController().signal)).nextCursor;
@@ -32,19 +51,23 @@ test('records become visible before EOF, task yields real scheduling points and
await f.runner.cancel();gate.resolve();await running;await f.runner.settle();assert.equal(f.root.job.status,'cancelled');assert.equal(f.root.document.elements.size,1);assert.deepEqual(f.root.job.committedSourceKeys,['first']);await f.runner.close();
});
// child 先于 parent 时按源键等待;parent 加入后即使被用户改名,随后 child 加入也不能用旧快照覆盖用户编辑。
test('a child encountered before its parent waits by source key, then joins without overwriting user edits',async()=>{
const f=fixture([{id:'child',dependencies:['parent']},{id:'parent'}]);const real=f.ports.commit;f.ports.commit=(units,next,expected)=>{const result=real(units,next,expected);if(result.status==='applied'&&units.some(unit=>unit.key==='parent')){const id=f.root.job.stableIds.parent,element=f.root.document.elements.get(id);f.root={...f.root,document:applyChanges(f.root.document,[{kind:'field',target:{id,incarnation:1},field:'data.title',before:element.data.title,after:'user edit'}])};}return result;};
await f.runner.start();assert.equal(f.root.job.status,'completed');assert.deepEqual(f.root.job.committedSourceKeys,['parent','child']);assert.equal(f.root.document.elements.get(f.root.job.stableIds.parent).data.title,'user edit');assert.equal(f.root.job.pending.length,0);assert.equal(f.errors.length,0);
});
// 多项批次含非法节点时先整批拒绝,再隔离独立坏项;good/tail 仍提交,失败元素不进入文档。
test('a rejected multi-record batch publishes no prefix, isolates the invalid record and retains independent content',async()=>{
const f=fixture([{id:'good',dependencies:['parent']},{id:'bad',dependencies:['parent'],bad:true},{id:'parent'},{id:'tail'}]);await f.runner.start();assert.equal(f.root.job.status,'partial');assert.ok(f.calls.some(keys=>keys.includes('good')&&keys.includes('bad')));assert.deepEqual(f.root.job.committedSourceKeys,['parent','good','tail']);assert.equal(f.root.document.elements.has(f.root.job.stableIds.bad),false);assert.equal(f.root.job.failures.find(failure=>failure.key==='bad').code,'invalid_geometry');assert.equal(f.errors.length,0);
});
// 前 100 项后发生解析错误并有缺依赖项,仍保留原内容和后来合法项;以 partial 记录两种失败,不插入悬空依赖。
test('record 101 failure preserves prior content and later valid records; missing dependencies report partial without dangling insertion',async()=>{
const input=Array.from({length:100},(_,index)=>({id:`n-${index}`}));input.push('{bad}',{id:'after'},{id:'edge',dependencies:['missing']});const f=fixture(input);await f.runner.start();assert.equal(f.root.job.status,'partial');assert.equal(f.root.document.elements.size,101);assert.equal(f.root.job.failures.length,2);assert.ok(f.root.job.failures.some(failure=>failure.code==='invalid_json'));assert.ok(f.root.job.failures.some(failure=>failure.code==='unresolved_dependency'));assert.equal(f.root.document.elements.has(f.root.job.stableIds.edge),false);validateImportJob(JSON.parse(JSON.stringify(f.root.job)));
});
// 待处理 locator 已保存后关闭再恢复,必须复用原 ID;已 committed key 对应元素后来被用户删除,也不能重导入复活。
test('a durable pending locator resumes with the same IDs; a committed key is not inserted again after deletion',async()=>{
const gate=deferred(),entered=deferred(),f=fixture([{id:'one',title:'original'}]);const persist=f.ports.persist;f.ports.persist=async()=>{await persist();if(f.root.job.pending.length&&!f.root.job.committedSourceKeys.length){entered.resolve();await gate.promise;}};
const task=f.runner.start();await entered.promise;const cut=f.saved.at(-1),closing=f.runner.close();gate.resolve();await task;await closing;
@@ -52,26 +75,31 @@ test('a durable pending locator resumes with the same IDs; a committed key is no
const id=resumed.root.job.stableIds.one,element=resumed.root.document.elements.get(id),deleted=applyChanges(resumed.root.document,[{kind:'element',id,before:element,after:null}]),again=fixture([],{source:f.source,root:{document:deleted,job:{...resumed.root.job,status:'paused',eof:false}},ports:{createId(){throw Error('reallocated identity');}}});await again.runner.start();assert.equal(again.root.document.elements.has(id),false);assert.equal(again.calls.length,0);
});
// 同 fingerprint 字节变更和 fingerprint 不同分别报告 source_changed/mismatch,暂停且不提交待处理项。
test('changed source bytes or source identity cannot claim a durable pending item',async()=>{
const oldSource=createNdjsonImportSource({bytes:bytes([{id:'one',title:'aaa'}]),fingerprint:'immutable-source'}),gate=deferred(),entered=deferred(),f=fixture([],{source:oldSource});const persist=f.ports.persist;f.ports.persist=async()=>{await persist();if(f.root.job.pending.length){entered.resolve();await gate.promise;}};
const task=f.runner.start();await entered.promise;const root=f.root,closing=f.runner.close();gate.resolve();await task;await closing;
for(const [title,fingerprint,code]of[['bbb','immutable-source','import_source_changed'],['aaa','different-source','import_source_mismatch']]){const source=createNdjsonImportSource({bytes:bytes([{id:'one',title}]),fingerprint}),r=fixture([],{source,root});await r.runner.start();assert.equal(r.root.job.status,'paused');assert.equal(r.root.job.error,code);assert.equal(r.root.document.elements.size,0);assert.equal(r.calls.length,0);}
});
// pending 容量满暂停但保留 locator 和映射;提高容量再开可继续同游标/ID,而不是丢弃或重新编号。
test('dependency backpressure pauses with locators intact and a larger budget resumes the same cursor and mapping',async()=>{
const f=fixture([{id:'child',dependencies:['parent']},{id:'parent'}],{ports:{maxPending:1}});await f.runner.start();assert.equal(f.root.job.status,'paused');assert.equal(f.root.job.error,'dependency_capacity');assert.equal(f.root.job.pending.length,1);const id=f.root.job.stableIds.child;
const r=fixture([],{source:f.source,root:f.root,ports:{maxPending:8,createId:()=> 'parent-id'}});await r.runner.start();assert.equal(r.root.job.status,'completed');assert.equal(r.root.job.stableIds.child,id);assert.equal(r.root.document.elements.size,2);
});
// 未来 job 版本、重复目标 ID 和缺乏结束条件的 completed cut 均在校验阶段拒绝。
test('future interpreter versions and malformed job cuts fail before any source read',()=>{
const job=createImportJob({id:'i',projectId:'p',sourceFingerprint:'s',parserVersion:1});assert.throws(()=>validateImportJob({...job,version:4}),/import_interpreter_unavailable/);assert.throws(()=>validateImportJob({...job,stableIds:{a:'same',b:'same'}}),/invalid_import_job/);assert.throws(()=>validateImportJob({...job,status:'completed'}),/invalid_import_job/);
});
// 便宜记录首项仍立即发布,随后可合并至最多 16 项;元数据发布次数受控,不能每条都重复发布完整状态。
test('fast independent records stage bounded publications and commits while the first record remains immediate',async()=>{
const f=fixture(Array.from({length:80},(_,index)=>({id:`batch-${index}`})),{ports:{now:()=>0,maxBatch:16,optimisticLocal:true}}),publish=f.ports.publish;let publications=0;f.ports.publish=(...args)=>{publications++;return publish(...args);};
await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,80);assert.deepEqual(f.calls[0],['batch-0']);assert.ok(f.calls.some(group=>group.length>1));assert.ok(f.calls.every(group=>group.length<=16));assert.ok(publications<20,`${publications} metadata publications for 80 records`);assert.equal(f.errors.length,0);validateImportJob(f.root.job);
});
// 第三项预读挂起时前两项已准备内容仍可见,源 read 始终串行;取消保留两项,不要求并发 seek 解锁。
test('a stalled speculative read cannot hide the ready staged prefix or require concurrent source seeks',async()=>{
const gate=deferred(),entered=deferred(),source=createNdjsonImportSource({bytes:bytes([{id:'first'},{id:'second'},{id:'third'}]),fingerprint:'immutable-source'}),signal=new AbortController().signal,first=await source.read(0,signal),second=await source.read(first.nextCursor,signal);let active=0;
const f=fixture([],{source:{...source,async read(cursor,signal){assert.equal(active,0,'concurrent source seeks');active++;try{if(cursor===second.nextCursor){entered.resolve();await gate.promise;}return await source.read(cursor,signal);}finally{active--;}}},ports:{now:()=>0,optimisticLocal:true}});
@@ -79,25 +107,30 @@ test('a stalled speculative read cannot hide the ready staged prefix or require
for(let i=0;i<100&&f.root.document.elements.size<2;i++)await new Promise(resolve=>setTimeout(resolve,1));assert.equal(f.root.document.elements.size,2);assert.deepEqual(f.root.job.committedSourceKeys,['first','second']);assert.equal(f.root.job.eof,false);await f.runner.cancel();gate.resolve();await running;assert.equal(f.root.document.elements.size,2);assert.equal(f.root.job.status,'cancelled');assert.equal(f.errors.length,0);
});
// 第二项转换后微任务取消,只保留首项;未发布的第二项游标、稳定 ID 和 pending 不能泄漏到 job。
test('cancelling during a staged decode discards unpublished cursor and identity changes',async()=>{
const f=fixture([{id:'first'},{id:'second'},{id:'third'}],{ports:{now:()=>0,optimisticLocal:true}}),prepare=f.ports.prepare;let cancelled;
f.ports.prepare=(line,job,reserve)=>{const unit=prepare(line,job,reserve);if(line.value.id==='second')queueMicrotask(()=>{cancelled=f.runner.cancel();});return unit;};
await f.runner.start();await cancelled;assert.equal(f.root.job.status,'cancelled');assert.deepEqual(f.root.job.committedSourceKeys,['first']);assert.equal(f.root.document.elements.size,1);assert.equal(Object.hasOwn(f.root.job.stableIds,'second'),false);assert.equal(f.root.job.pending.length,0);assert.equal(f.errors.length,0);validateImportJob(f.root.job);
});
// 源 I/O 在已准备项之后失败,先发布 ready 前缀再 paused 并报告原错误,不能把已就绪内容藏在错误后。
test('a source I/O failure is reported after publishing all already prepared ready records',async()=>{
const raw=createNdjsonImportSource({bytes:bytes([{id:'first'},{id:'second'}]),fingerprint:'immutable-source'}),first=await raw.read(0,new AbortController().signal),second=await raw.read(first.nextCursor,new AbortController().signal),f=fixture([],{source:{...raw,async read(cursor,signal){if(cursor===second.nextCursor)throw fault('source_offline');return raw.read(cursor,signal);}},ports:{now:()=>0,optimisticLocal:true}});
await f.runner.start();assert.equal(f.root.job.status,'paused');assert.equal(f.root.job.error,'source_offline');assert.deepEqual(f.root.job.committedSourceKeys,['first','second']);assert.equal(f.root.document.elements.size,2);assert.equal(f.root.job.pending.length,0);assert.equal(f.errors.length,1);
});
// A/B/C 循环即使 maxBatch=1 仍作为一个完整原子组提交一次,不能用软批量限制拆开依赖环。
test('a reference cycle commits every member in one root even when the adaptive batch budget is one',async()=>{
const f=fixture([{id:'A',dependencies:['B']},{id:'B',dependencies:['C']},{id:'C',dependencies:['A']}],{ports:{maxBatch:1}});await f.runner.start();assert.equal(f.root.job.status,'completed');assert.deepEqual(f.calls,[['A','B','C']]);assert.equal(f.commits.length,1);assert.equal(f.root.document.elements.size,3);validateImportJob(f.root.job);
});
// 依赖环一项非法时所有环成员共同失败,任何尝试都含完整环;无关 tail 仍成功,不能单独隔离并发布环成员。
test('a rejected reference cycle never isolates or publishes one member; independent content survives',async()=>{
const f=fixture([{id:'A',dependencies:['B']},{id:'B',dependencies:['A'],bad:true},{id:'tail'}]);await f.runner.start();assert.equal(f.root.job.status,'partial');assert.deepEqual(f.root.job.committedSourceKeys,['tail']);assert.ok(f.calls.filter(keys=>keys.includes('A')||keys.includes('B')).every(keys=>keys.includes('A')&&keys.includes('B')));assert.deepEqual(f.root.job.failures.map(value=>value.key),['A','B']);assert.equal(f.root.document.elements.size,1);
});
// 整环 locator 保存时文档仍为空;恢复在同一次提交中加入所有成员,不重新分配 ID 或暴露半组。
test('a durable pending cycle resumes without reallocating IDs or exposing a partial group',async()=>{
const gate=deferred(),entered=deferred(),f=fixture([{id:'A',dependencies:['B']},{id:'B',dependencies:['A']}]);const persist=f.ports.persist;f.ports.persist=async()=>{await persist();if(f.root.job.pending.length===2&&!f.root.job.committedSourceKeys.length){entered.resolve();await gate.promise;}};
const running=f.runner.start();await entered.promise;const cut=f.root,closing=f.runner.close();gate.resolve();await running;await closing;assert.equal(cut.document.elements.size,0);
@@ -105,16 +138,19 @@ test('a durable pending cycle resumes without reallocating IDs or exposing a par
});
// 新 job 为 v3;显式可读的 v1 旧 job 恢复后在首个 published cut 升级为 v3,不能继续发布旧解释器形态。
test('new jobs carry interpreter version 3 and a resumed legacy job upgrades in its first published cut',async()=>{
const f=fixture([{id:'one'}]);assert.equal(f.root.job.version,3);f.root={...f.root,job:{...f.root.job,version:1}};validateImportJob(f.root.job);const publish=f.ports.publish;f.ports.publish=(job,expected)=>{assert.equal(job.version,3);return publish(job,expected);};await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.job.version,3);
});
// 乐观本地已发布两项时,宿主 durability budget 可阻止第三项;到 EOF 后仍先持久化 running 内容,再声明 completed。
test('optimistic local checkpoints use host backpressure without declaring completion ahead of persistence',async()=>{
const gate=deferred(),entered=deferred(),f=fixture([{id:'one'},{id:'two'},{id:'three'}],{ports:{optimisticLocal:true,maxBatch:1}});let blocked=false;
f.ports.awaitDurabilityBudget=async()=>{if(!blocked&&f.root.document.elements.size===2){blocked=true;entered.resolve();await gate.promise;}};
const running=f.runner.start();await entered.promise;assert.equal(f.root.document.elements.size,2);assert.equal(f.root.job.status,'running');assert.equal(f.root.job.committedSourceKeys.length,2);gate.resolve();await running;assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,3);assert.ok(f.saved.some(cut=>cut.job.status==='running'&&cut.job.eof&&cut.document.elements.length===3));
});
// 最终内容 cut 或 completed 元数据保存失败均 paused;修复后续跑不重复 committed key 或 ID,仅补完持久化终态。
test('both final content and completion-metadata storage failures pause resumably without replaying committed keys',async()=>{
for(const phase of ['content','metadata']){
const f=fixture([{id:'one'},{id:'two'}],{ports:{optimisticLocal:true,awaitDurabilityBudget(){}}}),persist=f.ports.persist;let broken=true;
@@ -123,46 +159,59 @@ test('both final content and completion-metadata storage failures pause resumabl
}
});
// 最后持久化屏障挂起时 pause 立即生效;迟到成功不得发布 completed,再次 start 才合法完成且不重复内容提交。
test('pausing during the final durability barrier prevents a late completion publication',async()=>{
const gate=deferred(),entered=deferred(),f=fixture([{id:'one'}],{ports:{optimisticLocal:true,awaitDurabilityBudget(){}}}),persist=f.ports.persist;
f.ports.persist=async()=>{if(f.root.job.eof){entered.resolve();await gate.promise;}return persist();};const running=f.runner.start();await entered.promise;const pausing=f.runner.pause();assert.equal(f.root.job.status,'paused');gate.resolve();await pausing;await running;assert.equal(f.root.job.status,'paused');await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.calls.length,1);
});
// partial 状态保存失败后恢复,只补存已有 unresolved_dependency 诊断,不能重复追加同一失败或内容。
test('resuming a failed partial-status write does not duplicate unresolved dependency diagnostics',async()=>{
const f=fixture([{id:'good'},{id:'waiting',dependencies:['missing']}],{ports:{optimisticLocal:true,awaitDurabilityBudget(){}}}),persist=f.ports.persist;let broken=true;
f.ports.persist=async()=>{if(broken&&f.root.job.status==='partial')throw fault('quota');return persist();};await f.runner.start();assert.equal(f.root.job.status,'paused');assert.equal(f.root.job.failures.length,1);broken=false;await f.runner.start();assert.equal(f.root.job.status,'partial');assert.equal(f.root.job.failures.length,1);assert.equal(f.root.document.elements.size,1);assert.equal(f.calls.length,1);
});
// parsed locator 已发布但宿主 parsed 阶段预算未放行,内容根仍为空;背压释放后才提交两项并完成。
test('a ready parser publication observes host backpressure before admitting its content root',async()=>{
const gate=deferred(),entered=deferred();let blocked=true;const f=fixture([{id:'one'},{id:'two'}],{ports:{optimisticLocal:true,maxBatch:1,awaitDurabilityBudget(stage){if(blocked){assert.equal(stage,'parsed');entered.resolve();return gate.promise;}}}});const running=f.runner.start();await entered.promise;assert.equal(f.root.job.pending.length,1);assert.equal(f.root.job.committedSourceKeys.length,0);assert.equal(f.root.document.elements.size,0);await ticks();assert.equal(f.root.document.elements.size,0,'ready content bypassed host budget');blocked=false;gate.resolve();await running;assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,2);assert.equal(f.errors.length,0);await f.runner.close();
});
// 异步 beforeCommit 挂起时不能提前发布文档或 committed key;期间 cancel 后,迟到 enabled:true 也不得提交。
test('async commit preparation cannot publish while waiting and cancellation fences a late successful result',async()=>{const gate=deferred(),entered=deferred(),f=fixture([{id:'image'}],{ports:{async beforeCommit(units,job,signal){assert.equal(f.root.document.elements.size,0);assert.ok(job.pending.length);entered.resolve();await gate.promise;return{enabled:true};}}});const running=f.runner.start();await entered.promise;assert.equal(f.commits.length,0);assert.equal(f.root.job.committedSourceKeys.length,0);await f.runner.cancel();gate.resolve();await running;await f.runner.settle();assert.equal(f.root.job.status,'cancelled');assert.equal(f.commits.length,0);assert.equal(f.root.document.elements.size,0);await f.runner.close();});
// 异步准备拒绝 denied 独立项后记录 asset_forbidden,另一个独立项仍发布,拒绝不冒充系统异常。
test('async preparation denial isolates independent groups without publishing the denied record',async()=>{const prepared=[],f=fixture([{id:'denied'},{id:'allowed'}],{ports:{async beforeCommit(units){prepared.push(units.map(unit=>unit.key));return units.some(unit=>unit.key==='denied')?{enabled:false,code:'asset_forbidden',targets:[]}:{enabled:true};}}});await f.runner.start();assert.equal(f.root.job.status,'partial');assert.ok(f.root.job.failures.some(row=>row.key==='denied'&&row.code==='asset_forbidden'));assert.equal(f.root.document.elements.size,1);assert.equal([...f.root.document.elements.values()][0].data.title,'allowed');assert.ok(prepared.length>=2);assert.equal(f.errors.length,0);await f.runner.close();});
// 异步准备期间 job.generation 外部递增,旧 attempt 返回后不能提交或改进度;新 start 可重新完成。
test('a generation change during async preparation prevents the old attempt from committing or changing progress',async()=>{const gate=deferred(),entered=deferred(),f=fixture([{id:'later'}],{ports:{async beforeCommit(){entered.resolve();await gate.promise;return{enabled:true};}}});const running=f.runner.start();await entered.promise;f.root={...f.root,job:{...f.root.job,generation:f.root.job.generation+1}};const generation=f.root.job.generation;gate.resolve();await running;assert.equal(f.commits.length,0);assert.equal(f.root.job.generation,generation);assert.equal(f.root.job.committedSourceKeys.length,0);await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,1);await f.runner.close();});
// NDJSON 扫描短行时也检查耗时预算,在返回已解码记录前 yield;yield 内取消须以 import_aborted 拒绝。
test('NDJSON checks elapsed time before returning a short scanned record and honors cancellation',async()=>{const controller=new AbortController();let clock=0,yields=0;const source=createNdjsonImportSource({bytes:new Blob([JSON.stringify({text:'x'.repeat(2048)})+'\n']),fingerprint:'timed',chunkBytes:256,now:()=>clock+=10,frameBudgetMs:6,yieldTask:async()=>{yields++;controller.abort();}});await assert.rejects(source.read(0,controller.signal),/import_aborted/);assert.equal(yields,1);});
// 已预分配大量 ID 的恢复 job,在 parsed/committed cut 都保留同一稳定表对象;不得重复分配或为每行复制整张表。
test('a resumed preallocated import retains the exact stable-ID table through parsed and committed checkpoints',async()=>{
const stableIds=Object.freeze(Object.fromEntries([...Array.from({length:1000},(_,i)=>['reserved-'+i,'unused-'+i]),['one','id-one'],['two','id-two']])),job={...createImportJob({id:'import',projectId:'project',sourceFingerprint:'immutable-source',parserVersion:1}),stableIds},f=fixture([{id:'one'},{id:'two'}],{root:{document:base,job},ports:{createId(){throw Error('preallocated ID was replaced');}}});await f.runner.start();assert.equal(f.root.job.status,'completed');assert.strictEqual(f.root.job.stableIds,stableIds);for(const cut of f.commits)assert.strictEqual(cut.job.stableIds,stableIds);assert.deepEqual(f.root.job.committedSourceKeys,['one','two']);assert.equal(f.errors.length,0);await f.runner.close();
});
// createId 是宿主对象方法,runner 必须保留 this receiver,计数和分配结果由原 ports 对象维护。
test('identity allocation keeps the caller port method receiver',async()=>{
const f=fixture([{id:'bound'}],{ports:{allocated:0,createId(){return `bound-${++this.allocated}`;}}});await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.job.stableIds.bound,'bound-1');assert.equal(f.ports.allocated,1);assert.equal(f.errors.length,0);await f.runner.close();
});
// 每次提交有固定成本时,首项仍立即显示,后续增长批量摊薄成本;昂贵工作后让出,不增加原耗时预算。
test('fixed-cost commits amortize ready records while preserving the immediate first record and yielding after expensive work',async()=>{
let clock=0;const f=fixture(Array.from({length:80},(_,i)=>({id:`fixed-${i}`})),{ports:{now:()=>clock,frameBudgetMs:6,maxBatch:16,optimisticLocal:true}}),commit=f.ports.commit;f.ports.commit=(units,...rest)=>{clock+=4+units.length*.05;return commit(units,...rest);};await f.runner.start();assert.equal(f.root.job.status,'completed');assert.deepEqual(f.calls[0],['fixed-0']);assert.ok(f.calls.some(group=>group.length>=8));assert.ok(f.calls.length<20);assert.ok(f.yields>0);assert.equal(f.errors.length,0);validateImportJob(f.root.job);
});
// 便宜的 700 条记录可使用最多 128 项上限并超过旧的 32 项批量,全部记录按 FIFO 提交且不丢失。
test('a cheap runner can use the engine-sized ceiling without dropping records or changing FIFO order',async()=>{
const records=Array.from({length:700},(_,index)=>({id:`wide-${index}`})),f=fixture(records,{ports:{maxBatch:128,now:()=>0}});await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,700);assert.ok(f.calls.some(batch=>batch.length>32));assert.ok(f.calls.every(batch=>batch.length<=128));assert.deepEqual(f.calls.flat(),records.map(row=>row.id));assert.equal(f.errors.length,0);await f.runner.close();
});
// 较大上限不能掩盖单批超时:超过 40 项的模拟提交变贵后下一批必须回退,全部源键仍按序保留。
test('the larger ceiling still backs off when a measured commit exceeds the unchanged time budget',async()=>{
let clock=0;const records=Array.from({length:500},(_,index)=>({id:`cost-${index}`})),f=fixture(records,{ports:{maxBatch:128,now:()=>clock}}),commit=f.ports.commit;f.ports.commit=(units,...args)=>{clock+=units.length>40?12:1;return commit(units,...args);};await f.runner.start();const sizes=f.calls.map(batch=>batch.length),wide=sizes.findIndex(size=>size>40);assert.ok(wide>=0);assert.ok(sizes[wide+1]<sizes[wide]);assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,500);assert.deepEqual(f.calls.flat(),records.map(row=>row.id));assert.equal(f.errors.length,0);await f.runner.close();
});
// 300 条导入形成的 stableIds/bindings 按预算封存,与同步 image 一致且映射数量完整。
test('growing import tables yield when encoded while keeping captured IDs and bindings stable',async()=>{
const {sealRecoveryImageAsync,sealRecoveryImage}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const f=fixture(Array.from({length:300},(_,i)=>({id:'encode-'+i})));await f.runner.start();
@@ -173,6 +222,7 @@ test('growing import tables yield when encoded while keeping captured IDs and bi
});
// 300 个尚未等到依赖的 locator 也需协作式编码;无正文发布时仍保存完整待处理依赖,不能为了切片丢项。
test('waiting import locators and dependencies encode cooperatively before their endpoints arrive',async()=>{
const {sealRecoveryImageAsync,sealRecoveryImage}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const f=fixture(Array.from({length:300},(_,i)=>({id:'waiting-'+i,dependencies:['absent']})));await f.runner.start();
+8
View File
@@ -1,8 +1,15 @@
/**
* 键盘输入路由的纯逻辑回归。通过上下文标记和简化closest/composedPath对象,测试输入优先级而不创建真实浏览器DOM。
* 回调计数用于确认被文本/IME/浮层认领的事件不会继续触发文档快捷键。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
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));}});
// 依次让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');});
// 用伪closest模拟select和注册浮层,并传空/无效路径项,确认上下文识别不依赖全局Element。
test('DOM input context recognizes registered overlays and native selects without browser globals',()=>{
const select={closest:selector=>selector.split(',').includes('select')?select: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: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',()=>{
const input={closest:selector=>selector.split(',').includes('textarea')?input:null},host={closest:()=>null};
const context=canvasKeyContext({target:host,composedPath:()=>[input,host]});let canceled=false;
+10
View File
@@ -1,21 +1,31 @@
/**
* 恢复日志的结构差分与重放测试。补丁需精确还原 JSON 的可观察顺序和内容,并保持先前状态根不变。
* 小修改应避免重复大 payload;非法路径、稀疏数组或超限变化不能被当作有效日志。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {diffJournal,applyJournal} from '../../src/lib/canvas-runtime/journal-delta.ts';
// 共同断言:计算差分、重放后逐字比较 JSON,同时确认原状态没被修改,返回补丁供体积断言。
const replay=(before,after)=>{const captured=JSON.stringify(before),patches=diffJournal(before,after);assert.ok(patches);const result=applyJournal(before,patches);assert.equal(JSON.stringify(result),JSON.stringify(after));assert.equal(JSON.stringify(before),captured);return patches;};
// 覆盖根值替换、数组缩短/增长、对象键重排、嵌套编辑及 own __proto__,验证精确往返且不污染对象原型。
test('journal patches preserve serialized property order, arrays, null and own dangerous-name payloads',()=>{
for(const[before,after]of [[null,{a:1}],[[1,2,3],[1,9]],[[1],[2,3,4]],[{a:1,b:2},{b:2,a:1}],[JSON.parse('{"__proto__":{"x":1}}'),JSON.parse('{"__proto__":{"x":2}}')],[{nested:[{id:'A',data:{text:'before'}}]},{nested:[{id:'A',data:{text:'after'}},{id:'B'}]}]])replay(before,after);
assert.equal({}.x,undefined);
});
// 1000 行大表只改最后一行再追加一行,要求两个补丁总编码小于 1000 字符,并保留冻结账本字节。
test('a small node update and appended table row do not repeat the unchanged large payload',()=>{
const rows=Array.from({length:1000},(_,i)=>({id:String(i),text:'x'.repeat(1000)})),before={rows,ledger:{body:'exact frozen bytes'}},after={rows:[...rows.slice(0,999),{...rows[999],text:'changed'},{id:'new',text:'new'}],ledger:before.ledger};
const patches=replay(before,after);assert.ok(JSON.stringify(patches).length<1000);assert.equal(patches.length,2);
});
// 逐一拒绝不存在的父路径、数组越界/字符串索引、非法 splice、未知 op 及 undefined;补丁数超限也拒绝,差分超限返回 null。
test('malformed deltas reject invalid paths, array holes, unsupported operations and invalid ranges',()=>{
for(const patch of [{op:'set',path:['missing','x'],value:1},{op:'set',path:['rows',9],value:1},{op:'set',path:['rows','0'],value:1},{op:'splice',path:['rows'],start:0,deleteCount:3,items:[]},{op:'splice',path:['rows'],start:-1,deleteCount:0,items:[]},{op:'remove',path:[]},{op:'set',path:[],value:undefined}])assert.throws(()=>applyJournal({rows:[1]},[patch]),/corrupt_recovery_delta/);
assert.throws(()=>applyJournal({},Array(10001).fill({op:'set',path:[],value:1})),/corrupt_recovery_delta/);
assert.equal(diffJournal(Array(10001).fill(0),Array(10001).fill(1)),null);
});
// 以可复现序列轮流追加、更新及删除,夹杂历史追加和对象键重排,每一代都通过共同往返与旧根不变检查。
test('mixed deterministic updates round-trip exactly across a chain without modifying earlier roots',()=>{
// 固定种子的线性同余序列生成器使 150 轮混合增删改可复现;不是随机压力或并发测试。
let before={rows:[],nested:{a:1,b:2},history:[]};let seed=71;const random=()=>{seed=(seed*1664525+1013904223)>>>0;return seed;};
for(let i=0;i<150;i++){const after=structuredClone(before);if(i%3===0)after.rows.push({id:i,text:String(random())});else if(i%3===1&&after.rows.length)after.rows[random()%after.rows.length].text=String(random());else if(after.rows.length)after.rows.splice(random()%after.rows.length,1);after.history.push(i);if(i%7===0)after.nested={b:i,a:random()};replay(before,after);before=after;}
});
+5
View File
@@ -1,7 +1,12 @@
/**
* JSON隔离函数的语义与所有权测试。以日期、toJSON、稀疏数组、特殊键及循环等输入核对JSON转换与深冻结。
* 所有权证明只认可函数自己产生的结果,不能因调用方freeze或clone就复用内部证明。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {detachJsonData,hasDetachedJsonData} from '../../src/lib/canvas-core/json-data.ts';
// 修改外部嵌套对象/日期不得影响结果;数组空洞等变null,特殊键保留,伪造冻结副本无证明,循环/BigInt明确抛错。
test('JSON detachment owns only its copied immutable result and preserves JSON semantics',()=>{
let calls=0;const external={nested:{text:'before'},date:new Date('2026-01-01Z'),custom:{toJSON(){calls++;return{value:2};}},array:[,undefined,NaN],['__proto__']:{safe:true}};
const value=detachJsonData(external);assert.equal(calls,1);assert.equal(hasDetachedJsonData(value),true);assert.equal(hasDetachedJsonData(external),false);
+12
View File
@@ -1,11 +1,19 @@
/**
* 操作账本与调度基础结构的单元测试。账本保存依赖、冻结请求和执行证据;队列及堆只决定处理顺序。
* 这里直接操作内存账本,验证状态机与压缩规则,不代表网络或持久存储已经完成。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDeque, createMinHeap } from '../../src/lib/canvas-runtime/structures.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 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: [] } });
// 先验证大量 FIFO 出入队后的顺序,再验证最小堆以截止时间为主、序号为次序,空容器均返回 undefined。
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);
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);
});
// 仅针对完全未发送的修改:改名和移动折叠进创建对象,随后删除可抵消整个创建过程,移动不能覆盖标题。
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 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 }]), []);
});
// 未知和已受理都保留“可能执行”证据并阻塞依赖删除;只有 applied 才放行,重派发仍使用原请求体。
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 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);
});
// 拒绝缺失依赖及同 ID 不同意图;旧代次落盘完成不能把后来新增的操作一并标记为持久化。
test('dependencies are explicit, duplicate identities conflict and late durable completion does not mark newer work durable', () => {
const ledger = createLedger();
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);
});
// 恢复后先保留可能执行的风险;明确零写入结果将原操作阻塞,并拒绝再把同一终态改写为成功。
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 restored = createLedger(original.getSnapshot());
+24
View File
@@ -1,11 +1,21 @@
/**
* 作用域加载协调器测试:多个订阅者可共享同一渐进源,但各自保留导航元数据、取消状态和快照生命周期。
* 块需绑定 query/权限/project/snapshot/version/sequence,完整标记后仍须正常 EOF;错误尾部只能保留只读前缀。
* 活动并发、排队、单响应字节/块数及校验后缓存分别受限,旧任务迟到清理不能删除新代次。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createScopedLoader, loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
// 完整查询作用域:账户、workspace、project/canvas、格式、过滤指纹、来源及权限/导航代次;导航代次不阻止共享源。
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project', canvasId: 'canvas', viewKey: 'canvas', format: 'v1', queryFingerprint: 'all', source: 'fixture', authEpoch: 1, navigationGeneration: 1 };
// 可手动 resolve/reject 的 Promise,用来暂停源迭代,检查取消与新代次的关系。
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
// 让出至 setImmediate,使源执行及清理有机会推进,不模拟实际网络延迟。
const tick = () => new Promise(resolve => setImmediate(resolve));
// 构造绑定查询键的合法加载块,extra 专供逐字段篡改;覆盖信息按序累积,value 使用冻结样本。
const chunk = (query, sequence = 0, complete = true, extra = {}) => ({ queryKey: loadQueryKey(query), projectId: 'project', snapshotToken: 'snapshot', serverVersions: { canvas: 3 }, permissionEpoch: query.authEpoch, sequence, coverage: [`part-${sequence}`], complete, byteLength: 2, value: Object.freeze({ part: sequence }), ...extra });
// 两个导航订阅共用一次渐进读取;取消 A 不取消 B,A 保留取消时前缀且不再更新,B 收齐覆盖并完成。
test('subscribers share a progressive read while retaining navigation scopes; cancelling one preserves the other', async () => {
const gate = deferred(), updates = []; let calls = 0, signal;
const loader = createScopedLoader({ current: () => true, async *load(query, abort) { calls++; signal = abort; yield chunk(query, 0, false); await gate.promise; yield chunk(query, 1); } });
@@ -19,41 +29,48 @@ test('subscribers share a progressive read while retaining navigation scopes; ca
assert.strictEqual(a.getSnapshot(),stopped);assert.strictEqual(b.getSnapshot(),result);
assert.deepEqual(updates, ['queued', 'loading', 'initial-ready']); b.release(); loader.close();
});
// 尾部失败保留已接受首块为 failed-with-partial;重新 acquire 必须启动新源,不复用失败 inflight。
test('tail failure keeps the accepted prefix and a fresh retry replaces the failed inflight entry', async () => {
let calls = 0;
const loader = createScopedLoader({ current: () => true, async *load(query) { calls++; yield chunk(query, 0, false); if (calls === 1) throw Object.assign(Error('tail_failed'), { code: 'tail_failed' }); yield chunk(query, 1); } });
const a = loader.acquire(scope); await assert.rejects(a.ready, { code: 'tail_failed' }); assert.equal(a.getSnapshot().status, 'failed-with-partial'); assert.equal(a.getSnapshot().chunks[0].value.part, 0);
const b = loader.acquire(scope); assert.equal((await b.ready).chunks.length, 2); assert.equal(calls, 2); a.release(); b.release(); loader.close();
});
// 源先给 complete 再抛尾部校验错误,ready 必须拒绝且观察者从未收到 complete 状态。
test('a complete marker is not authoritative before iterator EOF; a checksum/trailing failure retains partial status', async () => {
const statuses = [], loader = createScopedLoader({ current: () => true, async *load(query) { yield chunk(query); throw Error('trailing_checksum'); } });
const handle = loader.acquire(scope, { onUpdate: value => statuses.push(value.status) }); await assert.rejects(handle.ready, /trailing_checksum/);
assert.equal(handle.getSnapshot().status, 'failed-with-partial'); assert.equal(statuses.includes('complete'), false); handle.release(); loader.close();
});
// 尾块换 snapshotToken、版本向量或 project 时拒绝,前缀 chunks/coverage 保留原值,不混入另一快照。
test('snapshot token, version vector and project changes reject the tail without mixing it into accepted coverage', async () => {
for (const extra of [{ snapshotToken: 'other' }, { serverVersions: { canvas: 4 } }, { projectId: 'other' }]) {
const loader = createScopedLoader({ current: () => true, async *load(query) { yield chunk(query, 0, false); yield chunk(query, 1, true, extra); } });
const handle = loader.acquire(scope); await assert.rejects(handle.ready); assert.equal(handle.getSnapshot().status, 'failed-with-partial'); assert.equal(handle.getSnapshot().chunks.length, 1); assert.deepEqual(handle.getSnapshot().coverage, ['part-0']); handle.release(); loader.close();
}
});
// 错误 queryKey、权限代次、序号、重复覆盖项、负字节数或非法版本在该块暴露前拒绝,结果为空 failed。
test('query, permission, sequence and malformed coverage headers reject before exposing a chunk', async () => {
for (const extra of [{ queryKey: 'foreign' }, { permissionEpoch: 2 }, { sequence: 3 }, { coverage: ['x', 'x'] }, { byteLength: -1 }, { serverVersions: { canvas: 0 } }]) {
const loader = createScopedLoader({ current: () => true, async *load(query) { yield chunk(query, 0, true, extra); } });
const handle = loader.acquire(scope); await assert.rejects(handle.ready, { code: 'invalid_load_chunk' }); assert.equal(handle.getSnapshot().chunks.length, 0); assert.equal(handle.getSnapshot().status, 'failed'); handle.release(); loader.close();
}
});
// EOF 前没有 complete,或 complete 之后又多一块,都不能成为完整结果,已有内容只作为失败前缀保留。
test('an incomplete EOF or extra chunk after a complete marker cannot become a complete result', async () => {
for (const extra of [false, true]) {
const loader = createScopedLoader({ current: () => true, async *load(query) { yield chunk(query, 0, extra); if (extra) yield chunk(query, 1); } });
const handle = loader.acquire(scope); await assert.rejects(handle.ready); assert.equal(handle.getSnapshot().status, 'failed-with-partial'); handle.release(); loader.close();
}
});
// 响应字节数或块数上限阻止接受第二块;保留首块计数,不能为满足预算静默丢块还宣称成功。
test('response byte and chunk budgets bound retained read copies while preserving earlier chunks', async () => {
for (const limits of [{ maxResponseBytes: 3 }, { maxChunks: 1 }]) {
const loader = createScopedLoader({ ...limits, current: () => true, async *load(query) { yield chunk(query, 0, false); yield chunk(query, 1); } });
const handle = loader.acquire(scope); await assert.rejects(handle.ready, { code: 'load_response_budget' }); assert.equal(handle.getSnapshot().byteLength, 2); assert.equal(handle.getSnapshot().chunks.length, 1); handle.release(); loader.close();
}
});
// 活动任务取消后仍占计算/读取槽位;旧迭代真正退出才启动新代次,其清理不能从共享表移除新任务。
test('cancelled active work retains its concurrency slot; its late cleanup cannot delete the newer generation', async () => {
const first = deferred(), second = deferred(), entered = []; let calls = 0;
const loader = createScopedLoader({ concurrency: 1, maxQueued: 1, current: () => true, async *load(query) { const index = ++calls; entered.push(index); await (index === 1 ? first.promise : second.promise); yield chunk(query); } });
@@ -63,30 +80,36 @@ test('cancelled active work retains its concurrency slot; its late cleanup canno
first.resolve(); await tick(); assert.equal(calls, 2); const peer = loader.acquire(scope); assert.equal(peer.getSnapshot().generation, b.getSnapshot().generation);
second.resolve(); await Promise.all([b.ready, peer.ready]); assert.deepEqual(entered, [1, 2]); b.release(); peer.release(); loader.close();
});
// 等待中的订阅取消可立即释放排队名额,放弃的 source 不执行;后续查询按空出的队列位置进入。
test('queued cancellation frees admission capacity and never executes the abandoned source', async () => {
const gate = deferred(), called = [], loader = createScopedLoader({ concurrency: 1, maxQueued: 1, current: () => true, async *load(query) { called.push(query.canvasId); await gate.promise; yield chunk(query); } });
const a = loader.acquire(scope), b = loader.acquire({ ...scope, canvasId: 'cancelled' }); const cancelled = assert.rejects(b.ready, { code: 'load_aborted' }); b.release(); await cancelled;
const c = loader.acquire({ ...scope, canvasId: 'next' }); gate.resolve(); await Promise.all([a.ready, c.ready]); assert.deepEqual(called, ['canvas', 'next']); a.release(); c.release(); loader.close();
});
// 已完成读取缓存受字节 LRU、显式 source validation 和权限时间限制;不认可、过期及墙上时钟回退均重新读取。
test('read cache requires explicit source validation and is bounded by byte LRU and permission age', async () => {
let time = 100, accepted = true; const calls = new Map();
const loader = createScopedLoader({ maxCacheEntries: 10, maxCacheBytes: 4, cacheMaxAgeMs: 100, now: () => time, acceptCached: () => accepted, current: () => true,
async *load(query) { calls.set(query.canvasId, (calls.get(query.canvasId) ?? 0) + 1); yield chunk(query); } });
// 缓存用例统一领取、等待及释放;只有显式 allow-validated 才允许考虑已缓存结果。
const read = async (canvasId, cached = false) => { const handle = loader.acquire({ ...scope, canvasId }, { cache: cached ? 'allow-validated' : 'fresh' }); await handle.ready; handle.release(); };
await read('a'); await read('b'); await read('a', true); assert.equal(calls.get('a'), 1); await read('c'); await read('b', true); assert.equal(calls.get('b'), 2);
accepted = false; await read('b', true); assert.equal(calls.get('b'), 3); accepted = true; time = 201; await read('b', true); assert.equal(calls.get('b'), 4);
time = 0; await read('b', true); assert.equal(calls.get('b'), 5); loader.close();
});
// 相同 canvas 的不同过滤指纹或权限 epoch 不能共享请求,三个查询各自加载。
test('different query fingerprints and permission epochs do not share requests', async () => {
let calls = 0; const loader = createScopedLoader({ current: () => true, async *load(query) { calls++; yield chunk(query); } });
const handles = [scope, { ...scope, queryFingerprint: 'filtered' }, { ...scope, authEpoch: 2 }].map(query => loader.acquire(query));
await Promise.all(handles.map(handle => handle.ready)); assert.equal(calls, 3); handles.forEach(handle => handle.release()); loader.close();
});
// 捕获 scope 只接管声明字段并冻结副本;外部额外对象仍可变,输入 canvas 后改不影响已领取查询。
test('scope capture owns only declared metadata without freezing unrelated caller state', async () => {
const extra={filter:'mutable'},input={...scope,extra};const loader=createScopedLoader({current:()=>true,async *load(query){yield chunk(query);}});
const handle=loader.acquire(input);assert.equal(Object.isFrozen(extra),false);input.canvasId='later';extra.filter='changed';
const result=await handle.ready;assert.equal(result.scope.canvasId,'canvas');assert.equal(Object.hasOwn(result.scope,'extra'),false);assert.equal(Object.isFrozen(result.scope),true);handle.release();loader.close();
});
// 身份失效拒绝迟到块;close 即使源不理 abort 也立即拒绝等待者,迟到完成不能重开 loader。
test('identity changes discard late chunks, and close promptly rejects waiting readers even if the source ignores abort', async () => {
const gate = deferred(); let current = true;
const loader = createScopedLoader({ current: () => current, async *load(query) { await gate.promise; yield chunk(query); } });
@@ -94,6 +117,7 @@ test('identity changes discard late chunks, and close promptly rejects waiting r
const pending = deferred(), closed = createScopedLoader({ current: () => true, async *load(query) { await pending.promise; yield chunk(query); } });
const b = closed.acquire(scope); const stopped = assert.rejects(b.ready, { code: 'loader_closed' }); closed.close(); await stopped; pending.resolve(); await tick(); assert.equal(b.getSnapshot().status, 'cancelled'); assert.throws(() => closed.acquire(scope), { code: 'loader_closed' });
});
// 观察者回调重入 acquire 不应重通知旧订阅者;新观察者抛错通过专用错误口收集,不使源读取失败。
test('observer reentrancy does not re-notify old subscribers or allow observer exceptions to fail a read', async () => {
const errors = []; let peer, updates = 0;
const loader = createScopedLoader({ current: () => true, onObserverError: error => errors.push(error), async *load(query) { yield chunk(query); } });
+97 -2
View File
@@ -1,3 +1,9 @@
/**
* 部分模型上的可编辑 ProjectEngine 集成测试。loading v1–v6 是加载状态解释器版本,逐步加入范围证书、提前同步、结构/任务/资源准入。
* working、原始 loadingRead 与最新 ACK 各有不同用途:后续旧流不能覆盖已确认编辑,未知请求也不能被新完整读当作未执行。
* 局部允许的操作仍要验证实际 changes、引用闭包及持久化证据,不能仅按命令名放行;完整接纳后才移除加载边界。
* fixture 使用内存存储和 Foundry persistence adapter 加假 transport,验证协调与协议检查,不证明真实服务或浏览器表现。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
@@ -5,26 +11,53 @@ import {createDocument,applyChanges,createRegistry,builtinComponents} from '../.
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
// 内置定义与 root/child UUID,满足 Foundry 协议的作用域校验。
const registry=createRegistry(builtinComponents()),root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002';
// 固定账户、workspace 与项目,使恢复读取命中同一持久化状态。
const scope={accountId:'10000000-0000-4000-8000-000000000003',workspaceId:'10000000-0000-4000-8000-000000000004',projectId:root};
// 第一代文本节点工厂,可指定 owner,基线正文 original 便于区分本地和远端改动。
const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
// root 的 A/B/F 与 child 的 C,通过共同 root 的边 E 形成加载期引用闭包。
const a=node('A'),b=node('B'),frame={...node('F'),componentId:'frame'},c=node('C',child),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'C'},path:null};
// prefix 只含 root/A,full 含五元素和 child;partial engine 不能因为某对象未到就推定其不存在。
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],prefix=createDocument(root,[canvases[0]],[a]),full=createDocument(root,canvases,[a,b,frame,c,edge]);
// 捕获源版本、loading v1 元数据和 A 的条件身份;后续协议版本在用例中显式补上证书。
const versions=new Map([[root,1],[child,1]]),state={version:1,sourceToken:'captured-read',declaredCanvasIds:[root,child],receivedElements:1,totalElements:5},target={id:'A',incarnation:1};
// 建立可重开的 image、文件、leader 与假服务端;extra 可覆盖加载状态、初始文档、时钟或资源端口。
function fixture(initial=full){
let image=null,leader,server=initial;const files=new Map();const serverVersions=new Map([...initial.canvases.keys()].map(id=>[id,1])),calls=[],receipts=new Map(),errors=[];
const storage={broken:false,saved:[],async claim(scope){assert.equal(leader,undefined);const held={scope,fence:1,active:true,async release(){leader=undefined;held.active=false;}};leader=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,leader);if(storage.broken)throw Error('quota');image=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},async readFile(_leader,id){return files.get(id);},files,close(){},image:()=>structuredClone(image)};
// 内存原子文件接口:校验 owner,broken 时在修改前失败,成功保存 image/文件和已保存快照列表。
const storage={broken:false,saved:[],
// 同一 fixture 只有一个存储 owner,release 清空 leader 并使旧 handle inactive;不模拟跨标签竞选。
async claim(scope){assert.equal(leader,undefined);const held={scope,fence:1,active:true,async release(){leader=undefined;held.active=false;}};leader=held;return held;},
// 返回 image 克隆,让重开真正走恢复解码,不复用旧引擎对象。
async recover(){return structuredClone(image);},
// 先验证 owner 和配额,再保存根/文件与删除列表;saved 记录实际提交内容,真实存储回滚不由此内存实现证明。
async commit(held,batch){assert.strictEqual(held,leader);if(storage.broken)throw Error('quota');image=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},
// 按 ID 读项目文件,供加载期导入与资源上传使用。
async readFile(_leader,id){return files.get(id);},files,
// 内存存储没有外部连接需要关闭,leader 生命周期由引擎归还。
close(){},
// 向故障注入/恢复断言交出克隆,避免检查代码意外改动当前已保存 image。
image:()=>structuredClone(image)};
// 把指定参与 canvas 的服务端内容编码为 canonical 模型;回执是局部模型,完整读取默认包含所有范围。
const model=(participants=[...serverVersions.keys()],complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...server.canvases.values()].filter(item=>participants.includes(item.id)),elements:[...server.elements.values()].filter(item=>participants.includes(item.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,serverVersions.get(id)])),removedCanvasIds:participants.filter(id=>!server.canvases.has(id))});
// GET 返回完整模型或保存回执;POST 检查 expected_versions,并按 requestId 幂等应用批次、递增参与版本。
const transport=async input=>{
if(input.method==='GET')return input.path.endsWith('/model')?{status:200,body:{model:model()}}:{status:200,body:receipts.get(input.path.split('/').at(-1))};
// 只计文档写请求,先验证每个参与 canvas 的 ACK 基线;已有 receipt 的同 ID 请求不得重复应用 changes。
calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],serverVersions.get(id)??0);
// 假服务先验证版本,再检查同 ID 是否已应用;只覆盖本文件的协调路径,不模拟完整生产幂等协议。
if(!receipts.has(input.requestId)){server=applyChanges(server,body.operations.flatMap(item=>item.changes));for(const id of participants)serverVersions.set(id,(serverVersions.get(id)??0)+1);receipts.set(input.requestId,{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(item=>item.id),model:model(participants,false)});}
return{status:200,body:receipts.get(input.requestId)};
};
// 使用真实 Foundry persistence 编解码,只有底层 transport 与存储被替换为测试实现。
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:root,registry,transport});
// 暴露服务端推进及版本副本供冲突注入;open 默认从 prefix/捕获版本/初级 loading 状态开始。
return{storage,persistence,calls,errors,server:()=>server,advance(changes){server=applyChanges(server,changes);serverVersions.set(root,serverVersions.get(root)+1);},currentVersions:()=>new Map(serverVersions),open:extra=>openProjectEngine({scope,registry,document:prefix,versions,loading:state,storage,persistence,createId:randomUUID,onError:error=>errors.push(error),...extra})};
}
// v1 可对已加载 A 编辑和 undo/redo 并保存,但不冻结请求;重开/接纳前缀不覆盖本地值,full 完成后才同步并保留历史。
test('loaded fields edit and undo/redo durably while no request freezes; full source adds tail and sends each edit once',async()=>{
const f=fixture();let engine=await f.open();try{
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}}).status,'applied');await engine.settle();
@@ -35,6 +68,7 @@ test('loaded fields edit and undo/redo durably while no request freezes; full so
assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.server().elements.get('A').geometry.x,25);assert.ok(f.calls.length>0);assert.equal(engine.getSnapshot().history.undo.length,2);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// 不完整范围拒绝结构、资源引用、移动和危险 presentation 写;公共入口不能删 loading 标记,stageFiles/copy 也不能绕过。
test('incomplete scopes block structure/resources/references and untrusted marker removal without changing the root',async()=>{
const f=fixture(),engine=await f.open();try{
for(const command of [{type:'element.remove',target},{type:'element.create',element:node('new')},{type:'element.move',target,canvasId:child},{type:'element.data',target,patch:{assetId:'asset'}},{type:'element.data',target,patch:{presentation:{scale:2}}}]){const before=engine.getSnapshot();assert.equal(engine.editor.dispatch(command).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);}
@@ -42,6 +76,7 @@ test('incomplete scopes block structure/resources/references and untrusted marke
assert.throws(()=>engine.stageFiles([{name:'x',bytes:new Blob(['x'])}],()=>{}),{code:'model_loading'});await assert.rejects(engine.imports.duplicate([target]),{code:'model_loading'});assert.equal(f.calls.length,0);
}finally{await engine.close();}
});
// 完整基线接纳必须先持久化才能释放全局发送 hold;配额失败仍无冻结请求,retry 保存后才发送原编辑。
test('full baseline must be durable before the hold releases; quota retry never sends from the partial cut',async()=>{
const f=fixture(),engine=await f.open();try{
engine.editor.dispatch({type:'element.data',target,patch:{title:'saved edit'}});await engine.settle();f.storage.broken=true;
@@ -49,6 +84,7 @@ test('full baseline must be durable before the hold releases; quota retry never
f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.server().elements.get('A').data.title,'saved edit');assert.ok(f.calls.length>0);assert.notEqual(f.storage.image().formatVersion,7);
}finally{f.storage.broken=false;await engine.close();}
});
// 来源 token 不匹配或已捕获前缀改变时拒绝且 root 不变;显式 fresh 完整读取可补同版本新记录,完成后旧 accept 不得降级状态。
test('late or changed prefix is rejected; a fresh full read preserves local edits and same-version new records',async()=>{
const f=fixture(),engine=await f.open();try{
engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const before=engine.getSnapshot();
@@ -57,6 +93,7 @@ test('late or changed prefix is rejected; a fresh full read preserves local edit
assert.equal((await engine.loading.complete(full,versions,{fresh:true})).status,'applied');await engine.settle();const done=engine.getSnapshot();assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.strictEqual(engine.getSnapshot(),done);
}finally{await engine.close();}
});
// 格式 7 的恢复 image 必须携带合法 loading 标记,降级格式或删标记都拒绝,不能误当普通完整文档打开。
test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/history without requests',async()=>{
const f=fixture(),engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const image=f.storage.image();assert.equal(decodeRecovery(image,registry).runtime.loading.sourceToken,state.sourceToken);
for(const formatVersion of [1,2,3,4,5,6])assert.throws(()=>decodeRecovery({...image,formatVersion},registry),{code:'unsupported_recovery_version'});
@@ -64,6 +101,7 @@ test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/
}finally{await engine.close();}
});
// fresh 全量读与本地字段冲突时保留原 partial 编辑且不发请求;通过用户审阅的 keep-local 计划协调后才结束加载。
test('a fresh conflicting completion keeps the original partial edit and resolves through the existing reviewed recovery path',async()=>{
const f=fixture(),engine=await f.open();try{
const edit=engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();
@@ -72,22 +110,27 @@ test('a fresh conflicting completion keeps the original partial edit and resolve
const session=await engine.prepareRecovery();assert.ok(session.conflict);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));assert.equal((await session.resolve(plan)).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// 重开虽传入 full,持久化 loading 仍需保留;显式 refresh 协调完整基线后保留原历史并同步编辑。
test('reopening a partial image with a complete fresh model finishes through refresh without replacing history',async()=>{
const f=fixture();let engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{title:'retained'}});await engine.settle();const history=engine.getSnapshot().history;await engine.close();engine=await f.open({document:full,loading:undefined});assert.ok(engine.loading.getState());assert.equal(engine.refresh(full,versions).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'retained');assert.deepEqual(engine.getSnapshot().history.undo,history.undo);}finally{await engine.close();}
});
// 命令表面只改标题,恶意 feature handler 实际写声明引用;检查生成 changes 后仍以 model_loading 拒绝整次发布。
test('permitted command cannot hide a reference write inside a feature handler',async()=>{
const {defineComponent,defineFeature}=await import('../../src/lib/canvas-core/index.ts');
// 故意声明一个会把 content.edit 转成引用字段修改的 feature,验证安全门检查最终 changes 而非命令表面 patch。
const custom=defineComponent({id:'unsafe-handler',dataVersion:1,structure:'node',validate:()=>true,resourceFields:[],copy:{references:[{path:['link'],target:'element',external:'preserve'}],omit:[]},features:[defineFeature('side-effect',{'content.edit':({element})=>[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.link',before:'old',after:'new',beforePresent:true,afterPresent:true}]})]});
const customRegistry=createRegistry([...builtinComponents(),custom]),record={...a,componentId:custom.id,data:{title:'A',link:'old'}},document=createDocument(root,[canvases[0]],[record]),f=fixture(),engine=await f.open({registry:customRegistry,document});
try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'allowed title'}}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
});
// 未来 loading 解释器、错误 received 数、空声明范围或伪造 frozen 请求均在恢复校验拒绝。
test('future loading interpreters, changed capture counts and forged frozen requests fail closed on recovery',async()=>{
const f=fixture(),engine=await f.open();try{await engine.persist();const source=f.storage.image();
for(const mutate of [image=>image.payload.runtime.loading.version=2,image=>image.payload.runtime.loading.receivedElements=99,image=>image.payload.runtime.loading.declaredCanvasIds=[],image=>image.payload.runtime.ledger.requests.push(['fake',{}])]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
}finally{await engine.close();}
});
// 新完整读带远端标题,本地几何重放不覆盖它;之后 undo 几何也不回滚远端无关字段。
test('fresh full read merges unrelated remote fields and geometry undo does not roll them back',async()=>{
const f=fixture(),engine=await f.open();try{
engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}});await engine.settle();f.advance([{kind:'field',target,field:'data.title',before:'A',after:'remote title',beforePresent:true,afterPresent:true}]);
@@ -96,31 +139,38 @@ test('fresh full read merges unrelated remote fields and geometry undo does not
}finally{await engine.close();}
});
// 身份失效后编辑、历史遍历、前缀接纳和 refresh 都拒绝且保持同一 root,不发送迟到工作。
test('identity fencing rejects edits, history traversal and late prefix publication without changing the root',async()=>{
const f=fixture();let current=true;const engine=await f.open({isCurrentIdentity:()=>current});try{engine.editor.dispatch({type:'element.data',target,patch:{text:'saved'}});await engine.settle();current=false;const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'late'}}).code,'identity_changed');assert.equal(engine.editor.history.undo().code,'identity_changed');assert.equal(engine.loading.accept(prefix,versions,state).code,'identity_changed');assert.equal(engine.refresh(full,versions).code,'identity_changed');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
});
// 多次 loading.resume 共用一次后台完整读取;完成保留本地编辑,之后旧流不能降级,也不再次启动多余读取。
test('background full reads are shared, preserve edits and cannot downgrade after completion',async()=>{
let release;const gate=new Promise(resolve=>release=resolve);const f=fixture();let reads=0;const persistence={...f.persistence,async read(){reads++;await gate;return{document:f.server(),versions:f.currentVersions()};}},engine=await f.open({persistence});
try{engine.editor.dispatch({type:'element.data',target,patch:{text:'background edit'}});await engine.persist();const first=engine.loading.resume(),second=engine.loading.resume();assert.strictEqual(first,second);while(!reads)await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,1);release();assert.equal((await first).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.text,'background edit');assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.equal((await engine.loading.resume()).status,'applied');assert.equal(reads,1);}finally{release();await engine.close();}
});
// 关闭项目中止正在等待的背景源并保存 partial 编辑;下一 owner 重开仍保留内容且未发送。
test('project close aborts a background read and persists its partial edits for the next owner',async()=>{
const f=fixture();let aborted=false,started;const reading=new Promise(resolve=>started=resolve);const persistence={...f.persistence,read:signal=>new Promise((resolve,reject)=>{started();signal.addEventListener('abort',()=>{aborted=true;reject(signal.reason);},{once:true});})};let engine=await f.open({persistence});
engine.editor.dispatch({type:'element.data',target,patch:{text:'survives source close'}});await engine.persist();const rejected=assert.rejects(engine.loading.resume());await reading;await engine.close();await rejected;assert.equal(aborted,true);assert.equal(f.storage.image().formatVersion,7);engine=await f.open();try{assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'survives source close');assert.equal(f.calls.length,0);}finally{await engine.close();}
});
// Workspace 并发 openPartial 返回同一 engine;背景同步已完成后,旧前台完整读不替换 root 或制造冲突。
test('Workspace opens one partial owner and ignores an older foreground result after background synchronization',async()=>{
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture();const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:()=>({persistence:f.persistence})});
try{const [first,second]=await Promise.all([workspace.openPartial(prefix,versions,state),workspace.openPartial(prefix,versions,state)]);assert.strictEqual(first,second);first.editor.dispatch({type:'element.data',target,patch:{title:'kept background'}});await first.loading.resume();await first.settle();const before=first.getSnapshot();assert.strictEqual(await workspace.open(full,versions),first);assert.strictEqual(first.getSnapshot(),before);assert.equal(first.getSnapshot().runtime.conflicts.size,0);assert.equal(workspace.getProjects().size,1);}finally{await workspace.close();}
});
// 背景 offline 上报一次并保留 partial cut;身份退役后的 resume 报 identity_changed,不继续向旧观察者上报源错误。
test('Workspace reports a background source failure while preserving the pending partial cut and fences retired reports',async()=>{
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture(),reports=[];let current=true;const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>current,onError:error=>reports.push(error),adapters:()=>({persistence:{...f.persistence,async read(){throw Error('offline');}}})});
try{const engine=await workspace.openPartial(prefix,versions,state);engine.editor.dispatch({type:'element.data',target,patch:{text:'held while offline'}});await engine.persist();await assert.rejects(workspace.resumeLoading(root),/offline/);assert.equal(reports.length,1);assert.ok(engine.loading.getState());assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);current=false;await assert.rejects(workspace.resumeLoading(root),/identity_changed/);assert.equal(reports.length,1);}finally{await workspace.close();}
});
// v2 计数证书可证明 child 成员完整但 root/关联边仍不完整;覆盖元数据与内容原子发布,重开保留,最终覆盖完成不虚增文档 revision。
test('certified scopes distinguish a complete child from an incomplete parent and publish coverage atomically',async()=>{
const loading={...state,version:2,canvasElementCounts:{[root]:4,[child]:1}},f=fixture();let engine=await f.open({loading});
try{
// 先保存旧查询视图;后续新覆盖发布后,旧视图仍描述原来的 loaded/unloaded 状态。
const first=engine.editor.getQueries();assert.equal(first.lookupElement('B').status,'unloaded');assert.equal(first.lookupElement('A').status,'present');assert.equal(first.membersResult(root).complete,false);assert.equal(first.hitNodes(root,{x:50,y:40}).complete,false);
const next=createDocument(root,canvases,[a,frame,c]);assert.equal(engine.loading.accept(next,versions,{...loading,receivedElements:3}).status,'applied');
const queries=engine.editor.getQueries();assert.equal(queries.membersResult(child).complete,true);assert.deepEqual(queries.membersResult(child).ids,['C']);assert.equal(queries.membersResult(root).complete,false);assert.equal(queries.childCanvasesResult(root).complete,true);assert.equal(queries.incidentResult('C').complete,false);assert.equal(queries.relations.referenceComplete,false);assert.equal(queries.coverage.projectComplete,false);assert.equal(first.lookupElement('C').status,'unloaded');
@@ -129,20 +179,24 @@ test('certified scopes distinguish a complete child from an incomplete parent an
for(const mutate of [image=>delete image.payload.runtime.loading.canvasElementCounts,image=>image.payload.runtime.loading.version=1,image=>image.payload.runtime.loading.version=99,image=>image.payload.runtime.loading.canvasElementCounts[child]=0]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
await engine.close();engine=await f.open({loading});assert.equal(engine.editor.getQueries().membersResult(child).complete,true);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'child edit');
assert.equal(engine.loading.accept(full,versions,{...loading,receivedElements:5}).status,'applied');const before=engine.getSnapshot(),oldQueries=engine.editor.getQueries();assert.equal(oldQueries.lookupElement('missing').status,'unloaded');assert.equal(oldQueries.membersResult(root).complete,true);
// 这里仅覆盖状态完成,文档内容已齐;root 可变化,但文档 revision 不应为元数据变化而虚增。
const completion=await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});assert.equal(completion.status,'applied');assert.equal(completion.revision,before.revision,'coverage-only completion must not invent a document revision');assert.equal(engine.editor.getQueries().lookupElement('missing').status,'absent');assert.equal(engine.editor.getQueries().coverage.projectComplete,true);assert.equal(oldQueries.lookupElement('missing').status,'unloaded');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'child edit');assert.equal(f.storage.image().formatVersion<7,true);
}finally{await engine.close();}
});
// 旧 v1 前缀即使收齐五元素也不能猜测某范围完整;同一读取中临时换成不同 count manifest 必须拒绝。
test('legacy prefixes do not infer scope completion; a changed count manifest cannot replace the published root',async()=>{
const f=fixture(),engine=await f.open();try{
assert.equal(engine.loading.accept(full,versions,{...state,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().membersResult(child).complete,false);
const before=engine.getSnapshot();assert.equal(engine.loading.accept(full,versions,{...state,version:2,canvasElementCounts:{[root]:4,[child]:1},receivedElements:5}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before);
}finally{await engine.close();}
});
// 零成员 child 也须其入口依赖已发布才标记 members complete;项目仍加载中,未知 ID 仍是 unloaded。
test('zero-member scopes are complete only once their entry dependencies publish',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame]),loading={...state,version:2,totalElements:2,receivedElements:2,canvasElementCounts:{[root]:2,[child]:0}},engine=await f.open({document,loading});
try{assert.deepEqual(engine.editor.getQueries().membersResult(child),{ids:[],complete:true});assert.equal(engine.editor.getQueries().lookupElement('not-here').status,'unloaded');}finally{await engine.close();}
});
// v2 允许完整连线 owner 内改 path,即使另一个 canvas 未完成;undo/redo 和格式 8 恢复保留路径,但 owner 不完整时禁用。
test('v2 admits path edits in a certified connection owner while another scope is incomplete, preserving undo and v8 recovery',async()=>{
const f=fixture(),loading={...state,version:2,receivedElements:5,totalElements:6,canvasElementCounts:{[root]:4,[child]:2}},engine=await f.open({document:full,loading}),path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:25},in:{x:0,y:0},out:{x:0,y:0}}]};
try{
@@ -154,6 +208,7 @@ test('v2 admits path edits in a certified connection owner while another scope i
const other=fixture(),incomplete=await other.open({document:full,loading:{...loading,canvasElementCounts:{[root]:5,[child]:1}}});try{assert.equal(incomplete.editor.can({type:'connection.path',target:{id:'E',incarnation:1},path}).enabled,false);}finally{await incomplete.close();}
});
// v3 完整 child 的修改可先同步,root 编辑继续等待;原流捕获版本和新 ACK 分开,旧后缀/最终完成不覆盖已确认 child 编辑。
test('v3 sends a certified child before the parent tail, retains its ACK through old chunks and completes the original source',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading});
try{
@@ -165,6 +220,7 @@ test('v3 sends a certified child before the parent tail, retains its ACK through
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'parent waits');assert.equal(f.server().elements.get('C').data.text,'original');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'A');
}finally{await engine.close();}
});
// v3 请求丢 ACK 时 fresh 完整接纳先等回执;重开查询原请求而不新发,保留 body/key,并拒绝篡改 loadingRead/旧流内容。
test('v3 unknown request reopens with original body/key, queries its receipt and rejects source tampering',async()=>{
let time=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},clock={now:()=>time,setTimer:()=>0,clearTimer(){}},persistence={...f.persistence,async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}};
let engine=await f.open({document,loading,persistence,clock});try{
@@ -177,28 +233,35 @@ test('v3 unknown request reopens with original body/key, queries its receipt and
}finally{await engine.close();}
});
// 背景完整读取在网络 lane 外等待未知回执;retry 先解决原请求再读 fresh 模型,不能占住网络位置阻止查询。
test('background completion waits outside the network lane for a v3 unknown receipt, then reads the fresh full model',async()=>{
let time=0,reads=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},clock={now:()=>time,setTimer:()=>0,clearTimer(){}},persistence={...f.persistence,async read(signal){reads++;return f.persistence.read(signal);},async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}},engine=await f.open({document,loading,persistence,clock});
try{engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'wait for original receipt'}});await engine.settle();const completion=engine.loading.resume();await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,0);time=1000;await engine.retry();assert.equal((await completion).status,'applied');await engine.settle();assert.equal(reads,1);assert.equal(f.calls.length,1);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'wait for original receipt');assert.equal(engine.loading.getState(),undefined);}finally{await engine.close();}
});
// fresh 背景读期间 hold 新网络准入,但仍保留当时提交的本地编辑;读完成后重放并只发送一次。
test('a fresh v3 background read fences new admissions while preserving edits made during the read',async()=>{
let release,started;const gate=new Promise(resolve=>release=resolve),reading=new Promise(resolve=>started=resolve),f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading,persistence:{...f.persistence,async read(signal){started();await gate;return f.persistence.read(signal);}}});
try{const completion=engine.loading.resume();await reading;engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'edited during fresh read'}});await engine.settle();assert.equal(f.calls.length,0);release();assert.equal((await completion).status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'edited during fresh read');assert.equal(f.calls.length,1);}finally{release();await engine.close();}
});
// v3 范围虽完整,源及编辑尚未落盘时仍不能 freeze;配额恢复后 retry 唤醒同一逻辑操作。
test('a v3 certified owner cannot freeze until its source and edit are durably saved; retry wakes the same logical intent',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading});
try{f.storage.broken=true;const edit=engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'durability first'}});await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setImmediate(resolve));assert.equal(engine.getSnapshot().runtime.ledger.requests.size,0);assert.equal(f.calls.length,0);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.calls.length,1);assert.equal(f.server().elements.get('C').data.text,'durability first');assert.equal(engine.getSnapshot().runtime.ledger.operations.get(edit.operationIds[0]).phase,'resolved');}finally{f.storage.broken=false;await engine.close();}
});
// 关闭回执等待取消订阅、保存未知请求,不额外发完整读取;格式 9 重开仍带原请求证据。
test('closing a v3 background receipt wait unsubscribes and saves the unknown request without issuing a fresh read',async()=>{
let reads=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading,clock:{now:()=>0,setTimer:()=>0,clearTimer(){}},persistence:{...f.persistence,async read(signal){reads++;return f.persistence.read(signal);},async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}}});
engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'retain on close'}});await engine.settle();const waiting=assert.rejects(engine.loading.resume());await new Promise(resolve=>setImmediate(resolve));await engine.close();await waiting;assert.equal(reads,0);assert.equal(f.calls.length,1);assert.equal(f.storage.image().formatVersion,9);assert.equal(decodeRecovery(f.storage.image(),registry).runtime.ledger.requests.size,1);
});
// 捕获源全量 ID→owner 清单,区分尚未加载的源 ID 与确实未占用的新 ID。
const identities=Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId]));
// loading v4 在范围计数外携带身份清单,允许完整 owner 内的安全结构修改。
const structuralLoading={...state,version:4,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1},elementOwners:identities};
// v4 身份清单证明 fresh ID 未占用,允许完整 child 内创建/确认/undo/redo;未加载 B 的 ID 仍禁复用,旧流完成保留第二代新元素。
test('v4 creates, acknowledges, undoes and restores a fresh element in a complete owner before an unrelated tail',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('fresh',child);
try{
@@ -208,6 +271,7 @@ test('v4 creates, acknowledges, undoes and restores a fresh element in a complet
assert.equal(engine.loading.accept(full,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('fresh').incarnation,2);assert.equal(engine.getSnapshot().runtime.versions.get(child),4);assert.equal(f.calls.length,3);assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// v4 frame/canvas 一次事务是一组历史,新 canvas 有独立版本;重开 undo/redo 后原流不删除新增范围或回退其版本。
test('v4 frame/canvas creation is one atomic history unit and new scope versions recover independently of the old stream',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),newCanvas='10000000-0000-4000-8000-000000000009',newFrame={...node('new-frame',child),componentId:'frame'};let engine=await f.open({document,loading:structuralLoading});
try{
@@ -217,6 +281,7 @@ test('v4 frame/canvas creation is one atomic history unit and new scope versions
}finally{await engine.close();}
});
// v4 原对象删除先等捕获引用闭包完整;本地新生节点/连线可成组创建删除,最终原流到齐后删除 A 也包含 E。
test('v4 defers original deletions until the captured reference closure is loaded, while born links and nodes can be removed atomically',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading});
try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.remove',target}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);
@@ -224,32 +289,40 @@ test('v4 defers original deletions until the captured reference closure is loade
assert.equal(engine.loading.accept(full,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.remove',target}).status,'applied');await engine.settle();assert.equal(f.server().elements.has('A'),false);assert.equal(f.server().elements.has('E'),false);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has('A'),false);assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// v4 普通改名保留源 AI 元数据;缺失/错归属身份清单及旧 image 格式不能伪装恢复。
test('v4 identity proof preserves source AI metadata during ordinary edits and rejects forged membership recovery',async()=>{
const old={...c,data:{...c.data,ai:{enabled:true}}},f=fixture(createDocument(root,canvases,[a,b,frame,old,edge])),document=createDocument(root,canvases,[a,frame,old]),engine=await f.open({document,loading:structuralLoading});
try{assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{title:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.errors.length,0);assert.equal(engine.getSnapshot().document.elements.get('C').data.ai.enabled,true);const saved=f.storage.image();decodeRecovery(saved,registry);for(const mutate of [image=>delete image.payload.runtime.loading.elementOwners,image=>image.payload.runtime.loading.elementOwners.C=root,image=>image.formatVersion=9]){const broken=structuredClone(saved);mutate(broken);assert.throws(()=>decodeRecovery(broken,registry));}}finally{await engine.close();}
});
// v4 完全未发送的创建/删除可抵消且零请求;undo 恢复同 ID 的第二代,后续旧流不使它回到旧生命周期。
test('v4 elides an unsent birth/deletion and restores the same ID with a fresh lifecycle before the source tail',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('unsent-birth',child);
try{assert.equal(engine.editor.dispatch({type:'element.create',element:created}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:created.id,incarnation:1}}).status,'applied');await engine.settle();assert.equal(f.calls.length,0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).incarnation,2);decodeRecovery(f.storage.image(),registry);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).incarnation,2);assert.equal(f.calls.length,1);}finally{await engine.close();}
});
// v4 新生节点换 owner 要求源/目标范围均可用;禁止移入未完成 root,允许 child/newCanvas 间移动并保留 undo。
test('v4 born-node ownership changes require both scopes and preserve their history while another source owner is incomplete',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),newCanvas='10000000-0000-4000-8000-000000000019',newFrame={...node('move-frame',child),componentId:'frame'},created=node('moving-birth',newCanvas);
try{assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:newFrame});tx.dispatch({type:'canvas.create',canvas:{id:newCanvas,parent:{canvasId:child,entryElementId:newFrame.id}}});tx.dispatch({type:'element.create',element:created});}).status,'applied');await engine.settle();assert.equal(engine.editor.can({type:'element.move',target:{id:created.id,incarnation:1},canvasId:root}).enabled,false);assert.equal(engine.editor.dispatch({type:'element.move',target:{id:created.id,incarnation:1},canvasId:child}).status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).ownerCanvasId,child);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).ownerCanvasId,newCanvas);assert.equal(engine.loading.getState().version,4);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// 源清单已声明 A 但它尚未加载时,即使存在第一代墓碑仍应是 unloaded;加载第二代后才能条件删除并变 absent。
test('v4 lookup does not confuse an unloaded restored element with its older tombstone',async()=>{
const restored={...a,incarnation:2},dead=new Map([['A',1]]),server=createDocument(root,canvases,[restored,b,frame,c,edge],dead),f=fixture(server),document=createDocument(root,canvases,[frame,c],dead),engine=await f.open({document,loading:{...structuralLoading,receivedElements:2}});
try{assert.equal(engine.editor.getQueries().lookupElement('A').status,'unloaded');assert.equal(engine.editor.getQueries().lookupElement('not-in-capture').status,'absent');assert.equal(engine.loading.accept(server,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().lookupElement('A').element.incarnation,2);assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'A',incarnation:2}}).status,'applied');await engine.settle();assert.equal(engine.editor.getQueries().lookupElement('A').status,'absent');}finally{await engine.close();}
});
// fresh 完整读可包含已确认新生对象,但不能在版本未变的不完整源范围凭空加入成员;正确后台读取保留新对象。
test('v4 fresh completion accepts acknowledged births but rejects an invented member in an unchanged incomplete source scope',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('fresh-before-background',child);
try{engine.editor.dispatch({type:'element.create',element:created});await engine.settle();const malformed=createDocument(root,canvases,[...f.server().elements.values(),node('invented')]);assert.equal((await engine.loading.complete(malformed,f.currentVersions(),{fresh:true})).code,'canonical_version_mismatch');assert.equal(engine.getSnapshot().document.elements.has(created.id),true);assert.equal((await engine.loading.resume()).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(created.id).incarnation,1);assert.equal(engine.getSnapshot().runtime.loadingRead,undefined);assert.equal(f.calls.length,1);}finally{await engine.close();}
});
// loading v5 再附源 canvas 树,让导入/复制能校验完整子树及其上级入边范围。
const jobLoading={...structuralLoading,version:5,sourceCanvases:canvases};
// 把原始元素封装为 native 导入行,测试任务准入与目标 owner 映射。
const ndjson=records=>new Blob(records.map(record=>JSON.stringify({kind:'element',key:record.id,record})+'\n'));
// v5 只允许导入到完整 child,root 未完整时准入就拒绝;合法任务在其他尾部仍加载时可发布/同步并保存 loadingAdmission。
test('v5 native import publishes and synchronizes into a complete owner while another captured owner remains incomplete',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:jobLoading});
try{assert.equal(engine.imports.canStart(root).enabled,false);assert.equal(engine.imports.canStart(child).enabled,true);assert.throws(()=>engine.imports.startNdjson(ndjson([node('source')]),root),{code:'loading_scope_incomplete'});assert.equal(engine.imports.getJobs().size,0);assert.equal(f.storage.files.size,0);
@@ -257,10 +330,12 @@ test('v5 native import publishes and synchronizes into a complete owner while an
assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(mapped).data.text,'hello');assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// v5 复制涉及的上级范围或声明子分区尚未完整,canDuplicate 和实际 duplicate 都拒绝,不能先分配任务/源文件。
test('v5 copy rejects an incomplete ancestor or a missing declared child before allocating task bytes',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:jobLoading});
try{const before=engine.getSnapshot(),selection=[{id:'C',incarnation:1}];assert.equal(engine.imports.canDuplicate(selection,{viewCanvasId:child}).enabled,false);await assert.rejects(engine.imports.duplicate(selection,{viewCanvasId:child}),{code:'loading_scope_incomplete'});assert.strictEqual(engine.getSnapshot(),before);assert.equal(engine.imports.getJobs().size,0);assert.equal(f.storage.files.size,0);}finally{await engine.close();}
});
// v5 无关第三 canvas 可继续加载,只要被复制子树及上级入边范围闭合;暂停/重开保留去重映射与 C 的单副本,不吸入未加载 V。
test('v5 deep copy includes upper incoming edges and a closed subtree while unrelated canvas contents are still loading',async()=>{
const other='10000000-0000-4000-8000-000000000029',g={...node('G'),componentId:'frame'},incoming={...edge,source:{elementId:'A'},target:{elementId:'F'}},topology=[...canvases,{id:other,parent:{canvasId:root,entryElementId:'G'}}],server=createDocument(root,topology,[a,frame,g,incoming,c,node('U',other),node('V',other)]),document=createDocument(root,topology,[a,frame,g,incoming,c,node('U',other)]),f=fixture(server),serverVersions=new Map([[root,1],[child,1],[other,1]]),loading={version:5,sourceToken:'closed-copy',declaredCanvasIds:[root,child,other],sourceCanvases:topology,receivedElements:6,totalElements:7,canvasElementCounts:{[root]:4,[child]:1,[other]:2},elementOwners:Object.fromEntries([...server.elements.values()].map(element=>[element.id,element.ownerCanvasId]))};
// The fixture's initial version vector follows the server's active scopes.
@@ -269,6 +344,7 @@ test('v5 deep copy includes upper incoming edges and a closed subtree while unre
}finally{await engine.close();}
});
// v5 paused 导入源重开仍用原 ID;loadingAdmission/token/范围/序号必须可验证,完成后 undo 的删除不会被源 full 重新复活。
test('v5 paused native source survives reopen, resumes with stable IDs, and undo does not revive after completion',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]);let engine=await f.open({document,loading:jobLoading});
try{const id=engine.imports.startNdjson(ndjson([node('paused-source','$target')]),child);await engine.imports.pause(id);await engine.settle();const paused=engine.imports.getJobs().get(id);assert.equal(paused.status,'paused');assert.equal(paused.committedSourceKeys.length,0);assert.equal(f.calls.length,0);assert.equal(f.storage.files.size,1);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.imports[0][1].source.loadingAdmission.sourceToken='other-cut',image=>image.payload.runtime.imports[0][1].source.loadingAdmission.requiredCanvasIds=[root],image=>image.payload.runtime.imports[0][1].source.loadingAdmission.operationSequence=999,image=>delete image.payload.runtime.loading.sourceCanvases,image=>image.formatVersion=10]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
@@ -276,37 +352,56 @@ test('v5 paused native source survives reopen, resumes with stable IDs, and undo
await engine.close();engine=await f.open({document,loading:jobLoading});assert.equal(engine.imports.getJobs().get(id).stableIds['element:paused-source'],mapped);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has(mapped),false);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(mapped),false);assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// v5 cancelled job 重开/resume 仍取消,源 full 完成只补原始五元素,不生成被取消任务的内容。
test('v5 cancelled native job stays cancelled through reopening and final source completion',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]);let engine=await f.open({document,loading:jobLoading});
try{const id=engine.imports.startNdjson(ndjson([node('cancelled-source','$target')]),child);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open({document,loading:jobLoading});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(f.calls.length,0);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// v5 已知入口但 child 尚未发布时,不可把它解释为空分区并允许深复制,任务表保持空。
test('v5 copy cannot interpret a declared but unpublished child as an empty partition',async()=>{
const f=fixture(),document=createDocument(root,[canvases[0]],[a,b,frame]),loading={...jobLoading,receivedElements:3,canvasElementCounts:{[root]:3,[child]:2},elementOwners:{A:root,B:root,F:root,C:child,E:child}};
const engine=await f.open({document,loading});try{assert.equal(engine.editor.getQueries().membersResult(root).complete,true);assert.equal(engine.getSnapshot().document.canvases.has(child),false);assert.equal(engine.imports.canDuplicate([{id:'F',incarnation:1}],{viewCanvasId:root}).enabled,false);await assert.rejects(engine.imports.duplicate([{id:'F',incarnation:1}],{viewCanvasId:root}),{code:'loading_scope_incomplete'});assert.equal(engine.imports.getJobs().size,0);}finally{await engine.close();}
});
// 动作可用性先检查目标范围、选择非空/身份新鲜度及策略;不可对 UI 声称可执行而到提交才发现明显缺项。
test('import availability rejects stale selection, missing target and policy denial before offering an action',async()=>{
const f=fixture();let engine=await f.open({document:full,loading:undefined});try{assert.equal(engine.imports.canStart('missing').code,'canvas_not_loaded');assert.equal(engine.imports.canDuplicate([],{viewCanvasId:root}).code,'empty_copy_selection');assert.equal(engine.imports.canDuplicate([{id:'A',incarnation:2}]).code,'stale_incarnation');}finally{await engine.close();}
engine=await f.open({document:full,loading:undefined,policy:()=>({enabled:false,code:'permission_denied'})});try{assert.equal(engine.imports.canStart(root).code,'permission_denied');assert.equal(engine.imports.canDuplicate([target]).code,'permission_denied');}finally{await engine.close();}
});
// loading v6 为完整 owner 中的文件暂存/上传和已有共享资源接纳增加明确准入证据。
const assetLoading={...jobLoading,version:6};
function assetPort(f){const uploads=[],receipts=new Map();let lose=false;const assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,bytes){const value=JSON.parse(request.body);assert.equal(f.storage.files.get(value.resource_id).size,bytes.size);assert.ok(f.storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.phase==='dispatch-marked'));uploads.push(request);const stored={id:value.resource_id,url:assets.sourceURL(value.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);if(lose)throw Error('lost upload ACK');return{outcome:'applied',canonical:stored};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:receipts.get(request.id)}:{outcome:'unresolved'};},accept:(_request,status)=>status.canonical};return{assets,uploads,lose(){lose=true;}};}
// 可记录上传和丢 ACK 的资源端口;上传前核对原 Blob 与 dispatch 证据已保存,成功资产可按原请求查询。
function assetPort(f){const uploads=[],receipts=new Map();let lose=false;const assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,
// 上传先核对原 Blob 大小及持久化 dispatch 标记;保存资产回执后 lose 开关可抛错模拟服务成功、客户端丢 ACK。
async upload(request,bytes){const value=JSON.parse(request.body);assert.equal(f.storage.files.get(value.resource_id).size,bytes.size);assert.ok(f.storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.phase==='dispatch-marked'));uploads.push(request);const stored={id:value.resource_id,url:assets.sourceURL(value.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);if(lose)throw Error('lost upload ACK');return{outcome:'applied',canonical:stored};},
// 按原 requestId 查保存的上传结果;未知保持 unresolved,不能用新上传掩盖未知请求。
async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:receipts.get(request.id)}:{outcome:'unresolved'};},
// 把 canonical 元数据交给引擎做身份验证与依赖解锁,此处不模拟服务端二进制上传本身。
accept:(_request,status)=>status.canonical};return{assets,uploads,
// 从此让上传回执丢失,用于重开后必须走查询且不重新传字节的场景。
lose(){lose=true;}};}
// 在 stageFiles 的同步事务内创建图片,返回资源句柄与提交结果,owner 可切换以验证范围拒绝。
function stageLoadingImage(engine,owner=child,id='partial-image'){let ref;const result=engine.stageFiles([{name:'picture.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([resource],tx)=>{ref=resource;tx.dispatch({type:'element.create',element:{...node(id,owner),componentId:'image',data:{title:'Picture',text:'',assetId:ref.id,src:ref.src}}});});return{result,ref};}
// v6 图片立即显示,完整 child 允许持久化后上传并同步,未完成 root 拒绝;恢复校验准入证据,undo/redo 不重复上传,旧源完成保留资源。
test('v6 displays, persists and uploads a scoped image before another owner tail; undo/redo and old completion preserve its resource',async()=>{
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{assert.equal(engine.canStageFiles(child).enabled,true);assert.equal(engine.canStageFiles(root).enabled,false);const before=engine.getSnapshot();assert.equal(stageLoadingImage(engine,root,'denied-image').result.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.storage.files.size,0);
const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);assert.equal(port.uploads.length,0);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(f.storage.image().formatVersion,12);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceToken='other-source',image=>image.payload.runtime.resources[0][1].loadingAdmission.canvasIds=[root],image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceURL='https://wrong.example/',image=>image.payload.runtime.resources[0][1].loadingAdmission.version=2,image=>{const row=image.payload.runtime.ledger.requests.find(([,row])=>row.kind==='asset.upload')[1];const body=JSON.parse(row.body);body.name='changed';row.body=JSON.stringify(body);},image=>image.formatVersion=11]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}assert.equal(engine.loading.getState().version,6);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('partial-image'),false);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('partial-image').incarnation,2);assert.equal(port.uploads.length,1);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().document.elements.get('partial-image').incarnation,2);assert.equal(f.errors.length,0);
}finally{await engine.close();}
});
// v6 上传服务成功但 ACK 丢失,复制图片可先本地复用待确认资源;重开查询原上传后释放两份图片文档,不重传字节或改 body。
test('v6 queries a lost upload ACK after reopening and never resubmits bytes or loses the pending image to the source tail',async()=>{
let now=0;const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),clock={now:()=>now,setTimer:()=>0,clearTimer(){}};port.lose();let engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});try{const {ref}=stageLoadingImage(engine);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.calls.length,0);const request=port.uploads[0];assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');const copyId=await engine.imports.duplicate([{id:'partial-image',incarnation:1}],{viewCanvasId:child});await engine.settle();const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');const copied=copy.stableIds['element:partial-image'];assert.equal(engine.getSnapshot().document.elements.get(copied).data.assetId,ref.id);assert.equal(f.calls.length,0);decodeRecovery(f.storage.image(),registry);await engine.close();now=10_000;engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});await engine.settle();assert.equal(port.uploads.length,1);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.server().elements.get(copied).data.assetId,ref.id);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(port.uploads.length,1);}finally{await engine.close();}
});
// v6 可替换完整原始 owner 中的图片引用;undo/redo 保留新旧选择且只上传一次,不因其他范围未加载而丢资源。
test('v6 replaces an image in a complete original owner and preserves both resource choices through undo/redo',async()=>{
const image={...c,componentId:'image',data:{title:'Original image',text:'',assetId:'old-asset',src:'https://assets.example/old-asset'}},server=createDocument(root,canvases,[a,b,frame,image,edge]),f=fixture(server),port=assetPort(f),document=createDocument(root,canvases,[a,frame,image]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{const result=engine.stageFiles([{name:'replacement.png',bytes:new Blob(['replacement'],{type:'image/png'})}],([ref],tx)=>tx.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{assetId:ref.id,src:ref.src}}));assert.equal(result.status,'applied');await engine.settle();const replacement=f.server().elements.get('C').data.assetId;assert.notEqual(replacement,'old-asset');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,'old-asset');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,replacement);assert.equal(port.uploads.length,1);decodeRecovery(f.storage.image(),registry);assert.equal(f.errors.length,0);}finally{await engine.close();}
});
// v6 Blob/root 配额失败时本地仍可显示字节,但上传和文档都不发;retry 完成保存后发送一次并保留单一创建历史。
test('v6 failed Blob/root persistence keeps the local picture visible and sends neither upload nor document until retry',async()=>{
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{await engine.persist();f.storage.broken=true;const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');const display=await engine.displayResource(ref.id);assert.equal(await(await fetch(display.url)).text(),'image bytes');display.release();await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(port.uploads.length,0);assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{f.storage.broken=false;await engine.close();}
});
// v6 已授权共享图片可接纳到完整 child,不创建上传任务;binding 和准入证据需恢复验证,重开显示/undo/redo 及旧流完成均保持资源身份。
test('v6 adopts an authorized shared image into a complete owner without an upload and restores its binding before the unread tail',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),bytes=new Blob(['shared 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(''),id='shared-adopted',authority='https://assets.example';
const cache=new Map(),commit=f.storage.commit;f.storage.assetDepot={async read(_leader,_authority,id){return cache.get(id);},async collect(){throw Error('unexpected GC');}};f.storage.commit=async(leader,batch)=>{const result=await commit(leader,batch);for(const write of batch.depot?.writes??[])cache.set(write.ref.asset.id,{ref:write.ref,bytes:write.bytes??cache.get(write.ref.asset.id)?.bytes});return result;};
+27
View File
@@ -1,14 +1,24 @@
/**
* 旧快照到 canonical 身份迁移的持久请求状态机测试。先 drain 旧写、读取最新源版本、保存冻结 body,再派发迁移。
* 未知结果保留原 ID/字节,恢复先查询后按原请求重试;只有验证过的终态才可清理恢复材料或放开旧编辑。
* 内存存储与假服务端注入响应丢失、版本冲突及配额问题;真实 UI/Web Locks 场景由 renderer 集成探针另测。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {createCanvasMigration,readCanvasMigration,canvasMigrationStorageKey,migrationBlocksCanvas} from '../../src/lib/canvas-migration.ts';
// 固定 UUID 作用域及新元素身份;源旧 ID 为 a,成功迁移须返回声明映射到新元素的证明。
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),scope={accountId:id(3),workspaceId:id(4)},registry=createRegistry(builtinComponents());
// 保存迁移字符串、回执、冻结 body 和请求记录,开关区分提交前失败、提交后丢 ACK、明确拒绝和篡改响应。
function fixture(){
const records=new Map(),receipts=new Map(),bodies=new Map(),calls=[];
const f={records,receipts,calls,version:1,canonical:false,current:true,quota:false,loseResponse:false,failBeforeCommit:false,reject:false,alter:undefined};let sequence=100;
// 模拟 localStorage;quota 在 setItem 前抛错,removeItem 真正删除恢复记录。
const storage={getItem:key=>records.get(key)??null,setItem(key,value){if(f.quota)throw Error('quota');records.set(key,value);},removeItem:key=>records.delete(key)};
// 完整 canonical 模型与新身份 id(20),供成功回执的版本、元素类型和 identity_map 交叉校验。
const model=version=>({documentFormatVersion:1,projectId:root,complete:true,canvases:[{id:root,parent:null}],elements:[{id:id(20),incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'body'},geometry:{x:0,y:0,width:240,height:72,rotation:0}}],versions:{[root]:version},tombstones:{},removedCanvasIds:[]});
// GET 分别查迁移需求/完整模型和原回执;POST 先断言已持久化同一 body,再按 requestId 防止重复应用。
const transport=async input=>{
calls.push(input);
if(input.method==='GET'&&input.path.endsWith('/model'))return f.canonical?{status:200,body:{model:model(f.version)}}:{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:f.version}}};
@@ -17,10 +27,13 @@ function fixture(){
const response=known?{status:200,body:known.body}:{status:404,body:{status:'unknown',request_id:key,error:'operation_receipt_unknown'}};
return f.alter?f.alter(response,input):response;
}
// 网络派发前直接读取保存字符串,对照请求体,证明持久化屏障而非只检查内存中存在请求。
assert.equal(JSON.parse(storage.getItem(canvasMigrationStorageKey(scope))).body,input.body,'request must be durable before POST');
assert.equal(input.responseFormat,'canvas-v2');
// 提交前失败没有服务端回执;恢复只能查询 unknown 后精确重试,不能据传输异常推定 applied。
if(f.failBeforeCommit)throw Error('connection lost before commit');
const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId);
// 已知 key 必须使用相同冻结字节;新 key 根据 reject 开关生成可信零写入拒绝或带映射/完整模型的成功。
let response=receipts.get(input.requestId);
if(response){assert.equal(bodies.get(input.requestId),input.body,'retry changed frozen bytes');}
else{
@@ -28,13 +41,17 @@ function fixture(){
receipts.set(input.requestId,response);bodies.set(input.requestId,input.body);
if(!f.reject){f.version++;f.canonical=true;}
}
// 此时服务端事实与回执已保存,再模拟 ACK 丢失;客户端看到错误也不能生成另一次迁移。
if(f.loseResponse)throw Error('connection lost after commit');
return f.alter?f.alter(structuredClone(response),input):response;
};
// 组装迁移控制器,默认 lock 只执行 work(不证明真实互斥);current、createId 和 signal 可被用例注入。
f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra});
// 从全部调用中过滤 POST,分别检查旧请求精确重试与明确重新准备产生新请求。
f.posts=()=>calls.filter(call=>call.method==='POST');return f;
}
// drain 必须在任何读请求前完成,之后重新读源版本;POST 使用 drain 后的版本和预先持久化 requestId,成功后清理记录。
test('migration drains first, refreshes source versions and persists exact request identity before dispatch',async()=>{
const f=fixture(),migration=f.make();let drained=false;
const result=await migration.start(root,async()=>{assert.equal(f.calls.length,0);drained=true;f.version=4;});
@@ -43,6 +60,7 @@ test('migration drains first, refreshes source versions and persists exact reque
assert.equal(f.posts()[0].requestId,JSON.parse(f.posts()[0].body).request_id);
});
// 服务提交成功但响应丢失,重建控制器先查原 key 即恢复成功;不再次 drain、不分配新 ID,也不发第二个 POST。
test('lost committed migration ACK is recovered by original query after reload without flushing or invoking twice',async()=>{
const f=fixture();f.loseResponse=true;const first=f.make();
assert.equal((await first.start(root,async()=>{})).status,'pending');const job=first.read();
@@ -52,6 +70,7 @@ test('lost committed migration ACK is recovered by original query after reload w
assert.equal(f.posts().length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/operations/'+job.id)));assert.equal(recovered.read(),undefined);
});
// 提交前断线的 durable 请求先 query unknown,再保留原 ID 和包含额外空白的 body 精确重试,不能重序列化。
test('a durable uncommitted request queries unknown then retries only its exact original body and key',async()=>{
const f=fixture();f.failBeforeCommit=true;const first=f.make();await first.start(root,async()=>{});
const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false;
@@ -61,6 +80,7 @@ test('a durable uncommitted request queries unknown then retries only its exact
assert.equal(f.calls.at(-2).method,'GET');assert.equal(f.calls.at(-2).body,undefined);
});
// 存储配额或旧 writer drain 失败均不得发迁移;drain 失败也不能留下新恢复任务或触发网络读取。
test('storage and legacy-drain failure prevent dispatch and do not silently discard recovery state',async()=>{
const f=fixture();f.quota=true;
await assert.rejects(()=>f.make().start(root,async()=>{}),/quota/);assert.equal(f.posts().length,0);
@@ -69,6 +89,7 @@ test('storage and legacy-drain failure prevent dispatch and do not silently disc
assert.equal(f.calls.length,0);assert.equal(f.records.size,0);
});
// 可信 terminal not-applied 才将 phase 置 rejected 并释放编辑 gate;resume 不重发,显式 start 才刷新版本并生成新 key。
test('only verified terminal rejection releases the editing gate; a new explicit preparation refreshes and allocates',async()=>{
const f=fixture();f.reject=true;const migration=f.make();const result=await migration.start(root,async()=>{});
assert.equal(result.status,'rejected');const rejected=migration.read();assert.equal(rejected.phase,'rejected');assert.equal(migrationBlocksCanvas(rejected,root),false);
@@ -77,6 +98,7 @@ test('only verified terminal rejection releases the editing gate; a new explicit
assert.equal((await migration.start(root,async()=>{})).status,'applied');assert.notEqual(f.posts()[1].requestId,rejected.id);assert.deepEqual(JSON.parse(f.posts()[1].body).expected_versions,{[root]:7});
});
// 裸 403、错 key/源版本/空或错类型/跨 canvas 映射不构成成功证明,保持 pending gate;原回执查询修复后仍只有一次 POST。
test('bare rejection and malformed or foreign success retain the frozen request until the original query repairs it',async()=>{
for(const alter of [
response=>({status:403,body:{error:'forbidden'}}),
@@ -92,6 +114,7 @@ test('bare rejection and malformed or foreign success retain the frozen request
}
});
// 未来迁移格式及同 workspace 另一 pending 根不能被覆盖;换账户读取不同存储键,不泄漏旧请求。
test('future/corrupt migration material and another pending project cannot be overwritten',async()=>{
const f=fixture(),key=canvasMigrationStorageKey(scope);f.storage.setItem(key,JSON.stringify({version:2,payload:'retain'}));
const saved=f.storage.getItem(key);await assert.rejects(()=>f.make().start(root,async()=>assert.fail()),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.calls.length,0);
@@ -100,6 +123,7 @@ test('future/corrupt migration material and another pending project cannot be ov
assert.equal(readCanvasMigration(f.storage,{...scope,accountId:id(99)}),undefined);
});
// 响应回来时身份已变或 signal abort,不能清理旧账户恢复材料;回到有效身份后查询原回执,不重复迁移。
test('identity changes and aborted lifetimes cannot clear an old account request on a late success',async()=>{
const f=fixture();f.alter=response=>{f.current=false;return response;};
await assert.rejects(()=>f.make().start(root,async()=>{}),/identity_changed/);assert.equal(f.records.size,1);
@@ -108,6 +132,7 @@ test('identity changes and aborted lifetimes cannot clear an old account request
await assert.rejects(()=>g.make({signal:controller.signal}).start(root,async()=>{}),{name:'AbortError'});assert.equal(g.records.size,1);assert.equal(g.posts()[0].signal,controller.signal);
});
// 同控制器重入 resume 被 migration_busy 拒绝;完成后新控制器识别已 canonical,只返回 already-migrated 不再 POST。
test('reentrant actions cannot create a second in-flight migration and already migrated projects do not POST',async()=>{
const f=fixture();let entered,release;const started=new Promise(resolve=>entered=resolve),gate=new Promise(resolve=>release=resolve);
const original=f.make();
@@ -117,6 +142,7 @@ test('reentrant actions cannot create a second in-flight migration and already m
assert.equal((await original.start(root,async()=>{})).status,'already-migrated');assert.equal(f.posts().length,1);
});
// 成功后清理记录失败仍保留可恢复请求,重试查询即可;假存储吞掉 setItem 时以 migration_not_durable 拒绝派发。
test('failed result retirement remains recoverable and a storage port that drops writes cannot dispatch',async()=>{
const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}};
await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/);
@@ -124,6 +150,7 @@ test('failed result retirement remains recoverable and a storage port that drops
const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0);
});
// 合法空模型允许 identity_map 为空,不应为了满足校验凭空生成不存在元素的映射。
test('a valid empty migration has no fabricated identity entries',async()=>{
const f=fixture();f.alter=response=>({...response,body:{...response.body,identity_map:[],model:{...response.body.model,elements:[]}}});
assert.equal((await f.make().start(root,async()=>{})).status,'applied');assert.equal(f.records.size,0);
+19
View File
@@ -1,3 +1,7 @@
/**
* Foundry 模型 loader、RuntimeHost 共享读取及画布 entry 注入测试。只读加载不应提前打开 workspace/project 存储。
* 并发请求可以共享进行中的读取,但空闲 canonical 快照不自动成为下次打开的权威基线;身份退役隔离迟到预览。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
@@ -5,13 +9,20 @@ import { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
import { loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
import { createFoundryModelLoader } from '../../src/lib/canvas-service/model-loader.ts';
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
// 确定性 UUID 工厂,满足 service 的输入校验并区分账户、workspace 与根 canvas。
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
// 当前身份和 registry;scope 中 projectId 尚未知,由 canonical 模型解析得到真实项目根。
const account = id(1), workspace = id(2), root = id(3), registry = createRegistry(builtinComponents());
// 完整模型查询绑定权限 epoch、导航代次、格式和来源,防止不同读取用途误共享。
const scope = { accountId: account, workspaceId: workspace, projectId: null, canvasId: root, authEpoch: 1, navigationGeneration: 7, format: 'canonical-model-v1', source: 'foundry', viewKey: 'canonical-project', queryFingerprint: 'full-project' };
// 手动 gate 控制首次响应及尾部预览返回,精确安排取消/身份退役。
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
// 推进一次事件循环,让共享 loader 和观察者执行至可检查的状态。
const tick = () => new Promise(resolve => setImmediate(resolve));
// 生成版本可变的完整 canonical 响应,标题可用于检测是否误用了旧账户结果。
const reply = (version = 1, title = account) => ({ status: 200, body: { model: { documentFormatVersion: 1, projectId: root, complete: true, canvases: [{ id: root, parent: null }], elements: [{ id: 'node', incarnation: 1, ownerCanvasId: root, componentId: 'text', dataVersion: 1, structure: 'node', data: { title, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0 } }], tombstones: {}, versions: { [root]: version }, removedCanvasIds: [] } } });
// JSON adapter 只发一个已验证完整块,绑定 query/project、canvas 覆盖和版本,GET 沿用原 AbortSignal。
test('Foundry adapter emits one validated complete chunk with bound query, project, coverage and version metadata', async () => {
const abort = new AbortController(), calls = [];
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async input => { calls.push(input); return reply(3); } });
@@ -20,12 +31,14 @@ test('Foundry adapter emits one validated complete chunk with bound query, proje
assert.deepEqual(chunks[0].coverage, [`canvas:${root}`]); assert.equal(chunks[0].serverVersions[root], 3); assert.ok(chunks[0].byteLength > 0); assert.equal(chunks[0].value.document.elements.get('node').data.title, account);
assert.strictEqual(calls[0].signal, abort.signal); assert.equal(calls[0].method, 'GET');
});
// 局部模型、查询 canvas 不在模型及不匹配迁移头均不能冒充初始就绪,必须在 yield 任何块前拒绝。
test('partial canonical replies, foreign requested canvases and unbound migration headers cannot masquerade as initial readiness', async () => {
for (const [response, expected] of [[{ ...reply(), body: { model: { ...reply().body.model, complete: false } } }, 'incomplete_initial_model'], [reply(), 'project_mismatch'], [{ status: 409, body: { error: 'canonical_migration_required', document_format_version: 1, root_id: root, source_versions: { [root]: 1 } } }, 'project_mismatch']]) {
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async () => response }), query = expected === 'incomplete_initial_model' ? scope : { ...scope, canvasId: id(99) };
await assert.rejects(async () => { for await (const _ of port(query, new AbortController().signal)) assert.fail('invalid response yielded a chunk'); }, { code: expected });
}
});
// 同身份/workspace 并发读取共享端口,取消一方不伤另一方;完成释放后下次重新读取新版本,始终不打开 workspace runtime。
test('Host shares concurrent model reads per identity/workspace but never reuses an idle authoritative snapshot', async () => {
const gate = deferred(), calls = []; let factories = 0, version = 1;
const host = createRuntimeHost({ registry, workspace() { assert.fail('readonly load opened a project/storage runtime'); }, modelLoader(accountId, workspaceId, _epoch, current) {
@@ -38,6 +51,7 @@ test('Host shares concurrent model reads per identity/workspace but never reuses
version = 2; assert.equal((await host.loadModel(account, workspace, root)).versions.get(root), 2); assert.equal(calls.length, 2); assert.equal(host.getWorkspaces().size, 0);
} finally { gate.resolve(); await host.close(); }
});
// 身份退役关闭旧 loader 并 abort 原信号,新账户用新读取;旧响应迟到不能覆盖当前 Host 身份或模型。
test('Host retirement aborts the old read and a late response cannot pollute the new account reader', async () => {
const gate = deferred(), calls = [];
const host = createRuntimeHost({ registry, workspace() { assert.fail('unexpected project open'); }, modelLoader(accountId, workspaceId) {
@@ -49,6 +63,7 @@ test('Host retirement aborts the old read and a late response cannot pollute the
gate.resolve(); await tick(); assert.equal(host.identity().accountId, id(8)); assert.equal(calls[0].input.signal.aborted, true); assert.equal(calls.length, 2);
} finally { gate.resolve(); await host.close(); }
});
// entry 解析 slug 后传入真实 account/workspace/canvas 与导航上下文,走注入 readModel;canonical 失败不能偷退回旧 canvas 接口。
test('entry injection uses resolved IDs and navigation context while preserving canonical-first legacy policy', async () => {
const board = { id: root, workspace_id: workspace, slug: 'board', kind: 'board', title: 'Board', version: 1 }, inputs = [], reads = [];
const options = { workspaceRef: 'workspace-slug', boardRef: 'board', registry, navigationGeneration: 9, request: async path => { reads.push(path); return path === 'profile' ? { id: account } : board; },
@@ -57,10 +72,12 @@ test('entry injection uses resolved IDs and navigation context while preserving
await assert.rejects(() => loadCanvasEntry({ ...options, readModel: async () => { throw Object.assign(Error('forbidden'), { code: 'forbidden' }); } }), { code: 'forbidden' });
});
// 两个预览观察者共享一个渐进源,取消一个后它不再收到后缀;另一方完成,Host 未创建任何存储 workspace。
test('Host previews share one source but cancelled observers never receive a late prefix or open storage',async()=>{
const gate=deferred(),aStates=[],bStates=[];let reads=0;
const first=reply().body.model.elements[0],second={...first,id:'second'},canvases=[{id:root,parent:null}],serverVersions={[root]:1};
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened storage');},modelLoader(){return async function*(scope){
// 每个原始读取共用 token/版本;part 将状态包装成连续序号块,gate 只阻塞后缀。
reads++;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'one-read',serverVersions,permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
yield part(0,{status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:2});
yield part(1,{status:'receiving',projectId:root,canvases:[],elements:[first],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:1,totalElements:2});
@@ -71,9 +88,11 @@ test('Host previews share one source but cancelled observers never receive a lat
const abort=new AbortController();const a=host.loadModel(account,workspace,root,{signal:abort.signal,onPreview:value=>aStates.push(value)}),b=host.loadModel(account,workspace,root,{onPreview:value=>bStates.push(value)});
try{while(!aStates.some(value=>value.receivedElements===1))await tick();assert.equal(reads,1);assert.equal(aStates.at(-1).document.elements.size,1);const rejected=assert.rejects(a,{code:'load_aborted'});abort.abort();await rejected;const count=aStates.length;gate.resolve();const complete=await b;assert.equal(complete.document.elements.size,2);assert.equal(aStates.length,count);assert.equal(bStates.at(-1).document.elements.size,2);assert.equal(host.getWorkspaces().size,0);}finally{gate.resolve();await host.close();}
});
// onPreview 抛错经 onObserverError 隔离,不阻止 canonical ready;身份退役后不能继续通知第二次读取的预览。
test('preview callback failures do not stop canonical loading and identity retirement fences future previews',async()=>{
const gate=deferred(),secondGate=deferred(),errors=[],states=[];let reading=0;
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened a runtime');},onObserverError:error=>errors.push(error),modelLoader(){return async function*(scope){
// 两次查询使用不同 gate,分别检查回调异常隔离和身份退役;每次源仍有独立接收/ready 阶段。
const pass=++reading;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'read',serverVersions:{[root]:1},permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
yield part(0,{status:'receiving',projectId:root,canvases:[{id:root,parent:null}],elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:0});await(pass===1?gate.promise:secondGate.promise);
yield part(1,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,[{id:root,parent:null}]),versions:new Map([[root,1]])},true);
+19
View File
@@ -1,15 +1,27 @@
/**
* 渐进 canonical 模型的只读预览投影测试。接收进度与合法可发布前缀不同:入口/归属/端口/端点依赖未闭合时先等待。
* 每次发布需是合法文档,失败保持上次快照并关闭预览;最终完整模型还需与已显示内容一致,不能直接变成可编辑半模型。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRegistry,builtinComponents,createDocument,validateDocument} from '../../src/lib/canvas-core/index.ts';
import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts';
// 内置类型定义及 root/child 身份;frame 端口与 child 成员形成依赖闭环。
const registry=createRegistry(builtinComponents()),root='root',child='child';
// 文本节点工厂,可替换 owner,用于构造已就绪节点、等待成员及非法记录。
const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
// F 在 root,端口引用 child 中 A;child 又以 F 为入口,必须闭合后一起显示。
const frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[{id:'p',nodeId:'A',direction:'output',handle:'right'}]}};
// A 与 B 作为跨分区连线端点;E 归共同 root,不能在任一端点缺失时显示成完整边。
const a=node('A',child),b=node('B'),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'B'},path:null};
// 源 canvas 树和版本表;预览开始时虽已知 child 关系,入口尚未来到仍不能发布 child。
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],versions=new Map([[root,1],[child,1]]);
// 空接收头声明总元素数,complete 仍由最终 ready 状态决定,不由 received==total 推定。
const head=(total=4)=>({status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions,receivedElements:0,totalElements:total});
// 后续 delta 不重复 canvas 头,只带新元素和接收计数,测试增量累积而非整文档替换。
const delta=(elements,received,total=4)=>({...head(total),canvases:[],elements,receivedElements:received});
// 先收 A/E 仍等待,独立 B 可先显示;F 到来才闭合 child/端口/连线并发布合法前缀,旧文档不变,收到全部元素仍不等于 complete。
test('partial model waits for entry/owner/port cycles and edge endpoints; publishes one closed legal prefix',()=>{
const preview=createModelPreview(registry);const first=preview.update(head());validateDocument(first.document,registry);assert.equal(first.document.canvases.size,1);
const second=preview.update(delta([a,edge],2));assert.equal(second.document.elements.size,0);assert.equal(second.waitingElements,2);assert.strictEqual(second.document,first.document);
@@ -17,32 +29,38 @@ test('partial model waits for entry/owner/port cycles and edge endpoints; publis
const fourth=preview.update(delta([frame],4));validateDocument(fourth.document,registry);assert.equal(fourth.document.elements.size,4);assert.equal(fourth.document.canvases.size,2);assert.equal(fourth.waitingElements,0);assert.equal(fourth.document.elements.get('E').ownerCanvasId,root);
assert.equal(first.document.elements.size,0);assert.equal(third.document.elements.size,1);assert.equal(fourth.complete,false);preview.close();assert.throws(()=>preview.update(delta([],4)),{code:'model_preview_closed'});
});
// 未知组件保持不透明只读候选,不猜测私有引用;已知 frame 声明端口缺目标时必须继续等待。
test('declared external reference waits; unsupported component data remains an opaque read-only candidate',()=>{
const preview=createModelPreview(registry);preview.update(head(2));const future={...node('future'),componentId:'plugin.future',dataVersion:99,data:{opaque:{ref:'absent',assetId:'secret'}}};
const shown=preview.update(delta([future],1,2));assert.deepEqual(shown.document.elements.get('future'),future);assert.equal(registry.resolve(shown.document.elements.get('future')),'unknown-component');
const delayed={...node('delayed'),componentId:'frame',data:{title:'delayed',text:'',ports:[{nodeId:'absent'}]}};
const next=preview.update(delta([delayed],2,2));assert.equal(next.document.elements.size,1);assert.equal(next.waitingElements,1);
});
// 同批 good+非法几何/身份/owner 或重复 ID 时整体拒绝,不能先发布 good,也不能替换上一合法快照。
test('invalid batches do not replace the last legal prefix or publish a partial batch',()=>{
for(const bad of [{...node('bad'),geometry:{...node('bad').geometry,width:-1}},{...node('bad'),incarnation:0},{...node('bad'),ownerCanvasId:'foreign'}]){
const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('good'),bad],2,2)));assert.strictEqual(preview.getSnapshot(),first);assert.equal(first.document.elements.size,0);
}
const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('same'),node('same')],2,2)));assert.strictEqual(preview.getSnapshot(),first);
});
// 循环/多根 canvas 拓扑和跨 project 的后续输入拒绝,不能混合成同一个预览。
test('bad topology and version/source switches cannot silently alter a preview',()=>{
for(const topology of [[{id:root,parent:{canvasId:child,entryElementId:'F'}},{id:child,parent:{canvasId:root,entryElementId:'G'}}],[{id:root,parent:null},{id:child,parent:null}]])assert.throws(()=>createModelPreview(registry).update({...head(),canvases:topology}));
const preview=createModelPreview(registry);preview.update(head());assert.throws(()=>preview.update({...delta([b],1),projectId:'other'}));
});
// 预览接管元素/头部数据副本,调用者后改不影响已显示内容;最终模型若与前缀不符,报 model_preview_changed。
test('owned preview records do not freeze or retain mutable caller payloads; final completion must match',()=>{
const preview=createModelPreview(registry),header=structuredClone(head(1)),element=node('one');preview.update(header);const state=preview.update(delta([element],1,1));
element.data.text='changed by caller';header.canvases[0].id='changed';assert.equal(state.document.elements.get('one').data.text,'');assert.equal(state.document.canvases.has(root),true);
const final=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[element]);assert.throws(()=>preview.update({status:'ready',document:final,versions}),{code:'model_preview_changed'});
});
// 后续块重复已发布 ID 属于非法输入,并使投影关闭;之后即使给合法新项也不能继续同一预览。
test('published identities cannot be repeated in later chunks and invalid input closes the projection',()=>{
const preview=createModelPreview(registry);preview.update({...head(2),canvases:[{id:root,parent:null}]});preview.update(delta([node('one')],1,2));assert.throws(()=>preview.update(delta([node('one')],2,2)));assert.throws(()=>preview.update(delta([node('two')],2,2)),{code:'model_preview_closed'});
});
// canvasElementCounts 统计收到记录,不把依赖等待者当已发布;证书先防御复制,单 canvas 数量不符在发布前拒绝。
test('scope certificates count received records without treating dependency-blocked members as published',()=>{
const preview=createModelPreview(registry);const counts={[root]:3,[child]:1};preview.update({...head(),canvasElementCounts:counts});counts[child]=0;
const waiting=preview.update(delta([a,edge],2));assert.equal(waiting.document.canvases.has(child),false);assert.equal(waiting.canvasElementCounts[child],1);assert.equal(waiting.waitingElements,2);
@@ -50,6 +68,7 @@ test('scope certificates count received records without treating dependency-bloc
const invalid=createModelPreview(registry),first=invalid.update({...head(1),canvasElementCounts:{[root]:0,[child]:1}});assert.throws(()=>invalid.update(delta([b],1,1)),{code:'invalid_model_coverage'});assert.strictEqual(invalid.getSnapshot(),first);
});
// elementOwners 清单包含尚未显示的 ID,按捕获归属校验后续记录;外部改清单无效,未声明 ID 的块不能发布。
test('custom loader identity manifests are owned and checked before a receiving batch can publish',()=>{
const preview=createModelPreview(registry),elementOwners={F:root,E:root,B:root,A:child};const first=preview.update({...head(),canvasElementCounts:{[root]:3,[child]:1},elementOwners});elementOwners.A=root;assert.equal(first.elementOwners.A,child);assert.deepEqual(first.sourceCanvases,head().canvases);assert.equal(first.document.canvases.has(child),false);assert.ok(Object.isFrozen(first.sourceCanvases));const before=preview.getSnapshot();assert.throws(()=>preview.update(delta([node('not-in-capture')],1)),{code:'invalid_model_identity'});assert.strictEqual(preview.getSnapshot(),before);
});
+25 -5
View File
@@ -1,3 +1,7 @@
/**
* Foundry 模型流 framing、完整校验及 transport/loader 协调测试。流可以先交付只读 delta,完整文档必须等终态记录与正常 EOF。
* 快照/序号/覆盖/身份清单绑定所有后续记录,错误尾部保留可读前缀但不能打开为权威基线。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
@@ -5,28 +9,39 @@ import {decodeModelStream,modelStreamType} from '../../src/lib/canvas-service/mo
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.ts';
import {createFoundryModelLoader} from '../../src/lib/canvas-service/model-loader.ts';
import {createScopedLoader} from '../../src/lib/canvas-runtime/loader.ts';
// 固定 UUID 作用域,满足 transport 与模型解析的身份约束。
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
// 内置定义和独立信号工厂,避免取消跨用例复用。
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
// 含中文及 emoji 的合法节点样本,验证逐字节传输时 UTF-8 边界处理。
const node={id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'中文',text:'🙂'},geometry:{x:0,y:0,width:100,height:80,rotation:0}};
// 每次新建 header/elements/complete 记录,快照 token 相同、序号连续且声明一个元素;便于逐字段篡改。
const records=()=>[{streamVersion:1,snapshotToken:'snapshot-a',sequence:0,kind:'header',documentFormatVersion:1,projectId:root,canvases:[{id:root,parent:null}],versions:{[root]:3},tombstones:{old:2},removedCanvasIds:[],elementCount:1},{streamVersion:1,snapshotToken:'snapshot-a',sequence:1,kind:'elements',elements:[structuredClone(node)]},{streamVersion:1,snapshotToken:'snapshot-a',sequence:2,kind:'complete',elementCount:1}];
// 编码为带末尾换行的 UTF-8 NDJSON;byteLength 断言以真实传输字节为准。
const encode=rows=>new TextEncoder().encode(rows.map(row=>JSON.stringify(row)).join('\n')+'\n');
// 单批字节异步源,不模拟网络切片,逐字节场景使用另一个 split generator。
async function* source(rows){yield encode(rows);}
// 排空异步迭代器到数组,让尾部或 EOF 校验错误也传播到测试断言。
const collect=async iterable=>{const result=[];for await(const item of iterable)result.push(item);return result;};
// canonical 完整项目查询作用域,交给通用 scoped loader 检查权限和查询绑定。
const scope={accountId:account,workspaceId:workspace,projectId:null,canvasId:root,authEpoch:1,navigationGeneration:1,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry'};
// 逐字节传入 Unicode/换行,依次输出接收头、元素 delta、最终 ready;正文/墓碑及总 byteLength 完整,记录冻结。
test('stream decoder frames split UTF8/newlines, emits deltas and validates one final complete model',async()=>{
const bytes=encode(records());async function* split(){for(const byte of bytes)yield Uint8Array.of(byte);}
const bytes=encode(records());/* 每批只发一个字节,主动拆开多字节 UTF-8 与记录边界。 */ async function* split(){for(const byte of bytes)yield Uint8Array.of(byte);}
const chunks=await collect(decodeModelStream(split(),root,registry,signal()));
assert.deepEqual(chunks.map(chunk=>[chunk.sequence,chunk.complete,chunk.value.status]),[[0,false,'receiving'],[1,false,'receiving'],[2,true,'ready']]);
assert.equal(chunks[1].value.elements[0].data.text,'🙂');assert.equal(chunks[2].value.document.elements.get('a').data.title,'中文');assert.equal(chunks[2].value.document.tombstones.get('old'),2);assert.equal(chunks.reduce((sum,chunk)=>sum+chunk.byteLength,0),bytes.length);assert.ok(Object.isFrozen(chunks[1].value.elements[0]));
});
// 快照切换、序号缺口、计数不符、重复 ID、错误 owner/几何/版本/拓扑、缺少结束及缺末换行都不能完成,超大行按流预算拒绝。
test('snapshot switches, sequence gaps, duplicate IDs, wrong counts, late records and invalid topology cannot finish',async()=>{
const cases=[rows=>rows[1].snapshotToken='other',rows=>rows[1].sequence=2,rows=>rows[0].elementCount=2,rows=>rows[2].elementCount=2,rows=>rows.push({...rows[2],sequence:3}),rows=>rows[1].elements.push(rows[1].elements[0]),rows=>rows[1].elements[0].ownerCanvasId=workspace,rows=>rows[1].elements[0].geometry.width=-1,rows=>rows[0].versions={},rows=>rows[0].canvases[0].parent={canvasId:root,entryElementId:'a'}];
for(const mutate of cases){const rows=records();mutate(rows);await assert.rejects(()=>collect(decodeModelStream(source(rows),root,registry,signal())));}
for(const rows of [[],records().slice(0,1),records().slice(0,2)])await assert.rejects(()=>collect(decodeModelStream(source(rows),root,registry,signal())));
async function* truncated(){yield encode(records()).slice(0,-1);}await assert.rejects(()=>collect(decodeModelStream(truncated(),root,registry,signal())),{code:'incomplete_model_stream'});
async function* huge(){yield new Uint8Array(8*1024*1024+1).fill(65);}await assert.rejects(()=>collect(decodeModelStream(huge(),root,registry,signal())),{code:'model_stream_budget'});
/* 删除文件末换行,验证终态记录也不能绕过 framing 完整性。 */ async function* truncated(){yield encode(records()).slice(0,-1);}await assert.rejects(()=>collect(decodeModelStream(truncated(),root,registry,signal())),{code:'incomplete_model_stream'});
/* 单行超过 8 MiB,没有换行,必须在无限积累前触发预算。 */ async function* huge(){yield new Uint8Array(8*1024*1024+1).fill(65);}await assert.rejects(()=>collect(decodeModelStream(huge(),root,registry,signal())),{code:'model_stream_budget'});
});
// transport 以 Accept 协商流并交出 ReadableStream;尾部 gate 前 loader 已看到首个元素 delta,放行后才 complete。
test('Foundry transport negotiates streaming without buffering; coordinator retains first delta before gated tail',async()=>{
let release,opened;const gate=new Promise(resolve=>release=resolve),started=new Promise(resolve=>opened=resolve);let requested;
const rows=records(),stream=new ReadableStream({async start(controller){controller.enqueue(encode(rows.slice(0,2)));opened();await gate;controller.enqueue(encode(rows.slice(2)));controller.close();}});
@@ -38,29 +53,34 @@ test('Foundry transport negotiates streaming without buffering; coordinator reta
release();const done=await handle.ready;assert.equal(done.status,'complete');assert.equal(done.chunks.at(-1).value.document.elements.size,1);
}finally{release();handle.release();loader.close();}
});
// 坏 JSON 尾部使 loader failed-with-partial,保留两个 receiving 块,不能输出权威模型。
test('malformed tails retain read deltas without yielding an authoritative model',async()=>{
async function* broken(){yield encode(records().slice(0,2));yield new TextEncoder().encode('{broken}\n');}
/* 合法头/元素后追加坏 JSON,保留可读前缀并拒绝最终模型。 */ async function* broken(){yield encode(records().slice(0,2));yield new TextEncoder().encode('{broken}\n');}
const states=[],loader=createScopedLoader({load:createFoundryModelLoader({workspaceId:workspace,registry,transport:async()=>({status:200,body:null,stream:broken()})}),current:()=>true});const handle=loader.acquire(scope,{onUpdate:value=>states.push(value)});
try{await assert.rejects(handle.ready);assert.equal(handle.getSnapshot().status,'failed-with-partial');assert.equal(handle.getSnapshot().chunks.length,2);assert.equal(handle.getSnapshot().chunks.at(-1).value.status,'receiving');}finally{handle.release();loader.close();}
});
// complete 记录已到但流未结束时 ready 仍未解决;随后垃圾尾行使读取失败,不能先打开基线再反悔。
test('terminal record still requires EOF; trailing garbage rejects rather than opening a baseline',async()=>{
const gate={},rows=records();let release;gate.promise=new Promise(resolve=>release=resolve);
async function* trailing(){yield encode(rows);await gate.promise;yield new TextEncoder().encode('x\n');}
/* complete 之后仍阻塞迭代器 EOF,之后追加垃圾,检查 ready 不会过早成功。 */ async function* trailing(){yield encode(rows);await gate.promise;yield new TextEncoder().encode('x\n');}
const loader=createScopedLoader({load:createFoundryModelLoader({workspaceId:workspace,registry,transport:async()=>({status:200,body:null,stream:trailing()})}),current:()=>true}),handle=loader.acquire(scope);let ready=false;handle.ready.then(()=>ready=true,()=>{});
try{while(handle.getSnapshot().chunks.length<2)await new Promise(resolve=>setImmediate(resolve));assert.equal(ready,false);release();await assert.rejects(handle.ready);assert.equal(handle.getSnapshot().status,'failed-with-partial');}finally{release();handle.release();loader.close();}
});
// abort 取消阻塞的 body reader;身份失效也拒绝下一次读取并取消底层流,分别核对 cancel 次数。
test('stream transport fences changed identity and abort cancels a blocked body reader',async()=>{
let current=true,cancelled=0;const abort=new AbortController();const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async()=>new Response(new ReadableStream({cancel(){cancelled++;}}),{headers:{'Content-Type':modelStreamType}})});
const reply=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1',signal:abort.signal});const iterator=reply.stream[Symbol.asyncIterator]();const pending=iterator.next();await Promise.resolve();abort.abort();await assert.rejects(pending);assert.equal(cancelled,1);
const other=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1'});current=false;await assert.rejects(()=>other.stream[Symbol.asyncIterator]().next(),{code:'identity_changed'});assert.equal(cancelled,2);
});
// 可选 canvasElementCounts 保持旧流兼容;非法清单或后续元素超过单 canvas 声明在发布前拒绝。
test('optional snapshot scope counts preserve legacy streams and reject invalid or exceeded coverage before publication',async()=>{
const rows=records();rows[0].canvasElementCounts={[root]:1};const seen=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(seen[0].value.canvasElementCounts,{[root]:1});assert.equal(Object.isFrozen(seen[0].value.canvasElementCounts),true);
for(const manifest of [null,[],{}, {[root]:-1},{[root]:.5},{[root]:2},{[root]:1,[workspace]:0}]){const broken=records();broken[0].canvasElementCounts=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_coverage'});}
const broken=records();broken[0].canvases.push({id:workspace,parent:{canvasId:root,entryElementId:'frame'}});broken[0].versions[workspace]=1;broken[0].canvasElementCounts={[root]:0,[workspace]:1};const iterator=decodeModelStream(source(broken),root,registry,signal())[Symbol.asyncIterator]();assert.equal((await iterator.next()).value.value.receivedElements,0);await assert.rejects(iterator.next(),{code:'invalid_model_coverage'});
});
// elementOwners 声明尚未加载的 ID 与 owner;非法清单或后续未捕获 ID 在发布前拒绝,清单自身冻结。
test('identity manifest covers unloaded IDs and binds each received record to its captured owner',async()=>{
const rows=records();rows[0].canvasElementCounts={[root]:1};rows[0].elementOwners={a:root};const chunks=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(chunks[0].value.elementOwners,{a:root});assert.ok(Object.isFrozen(chunks[0].value.elementOwners));
for(const manifest of [null,[],{}, {other:workspace},{a:root,other:root}]){const broken=structuredClone(rows);broken[0].elementOwners=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_identity'});}
+7
View File
@@ -1,10 +1,17 @@
/**
* 网络准入门的微任务级测试。容量设为1,用手动Promise控制排队顺序,验证拿到名额后仍要核对身份与取消。
* 这里不发真实HTTP,也不测试服务端幂等或浏览器网络栈。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createNetworkGate} from '../../src/lib/canvas-runtime/network.ts';
/** 暴露resolve的手动等待点,让首请求占住名额,测试可在第二请求获准前改变身份或abort。 */
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{resolve,promise};};
// 首请求等待时使身份失效,次请求不执行;恢复身份后第三请求能执行,证明拒绝的名额已释放。
test('transport rechecks identity after waiting for capacity and releases a rejected permit',async()=>{
let current=true,calls=0;const gate=createNetworkGate(1,()=>{if(!current)throw Error('identity_changed');}),hold=deferred(),signal=new AbortController().signal,first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(signal,async()=>{calls++;}),/identity_changed/);await Promise.resolve();current=false;hold.resolve();await first;await second;assert.equal(calls,1);current=true;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);
});
// 排队请求先abort,随后释放首请求并运行第三个;调用数与护栏计数都只能包含真正获准的两个任务。
test('an aborted queued transport cannot pass the effect guard or consume the next capacity grant',async()=>{
let guards=0,calls=0;const gate=createNetworkGate(1,()=>guards++),hold=deferred(),signal=new AbortController().signal,abort=new AbortController(),first=gate.run(signal,async()=>{calls++;await hold.promise;}),second=assert.rejects(gate.run(abort.signal,async()=>{calls++;}),/cancelled/);await Promise.resolve();abort.abort(Error('cancelled'));await second;hold.resolve();await first;await gate.run(signal,async()=>{calls++;});assert.equal(calls,2);assert.equal(guards,2);
});
+7
View File
@@ -1,12 +1,19 @@
/**
* 节点连接点测量的批处理测试。用任务数组代替调度器,验证大量invalidate合并、每挂载owner租约与store隔离。
* 10000节点断言是确定性批次数量检查,不是浏览器布局或帧率性能测试。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
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',()=>{
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',()=>{
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',()=>{
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();
});
+37 -2
View File
@@ -1,46 +1,81 @@
/**
* 本地文件盘点、保留期和回收的引擎测试。是否孤立由所有已知引用及保守覆盖判断,不能仅看资源登记表。
* 内存端口声明 atomic-v1 文件删除能力,随镜像保存模拟文件增加/删除;不验证真实文件系统的原子性。
* 清理不改变业务文档和历史,退休身份则必须持久保留,防止稍后误用同 ID 的已删除字节。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
import {orphanCoverage} from '../../src/lib/canvas-runtime/orphan-files.ts';
// 图片目标默认没有 assetId,各场景自行添加引用;文件 Map 可包含尚未登记但仍被元素引用的字节。
const registry=createRegistry(builtinComponents()),scope={accountId:'a',workspaceId:'w',projectId:'p'},node=(id,data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:'C',componentId:'image',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...data}}),original=createDocument('p',[{id:'C',parent:null}],[node('A')]);
// 用可控 Promise 挂起读字节,模拟显示借用、取消和 Root 变化与回收扫描交错。
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
// 将文件 Map、业务模型、内存镜像和服务替身组合,保留批次与请求记录用于检查回收是否越界。
function fixture(files=new Map(),document=original){
let image,owner,next=0,server=document,version=1,now=1000;const batches=[],requests=[],receipts=new Map();
const storage={fileDeletion:'atomic-v1',async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;},async recover(){return image?structuredClone(image):null;},async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};},async readFile(leader,id){assert.equal(leader,owner);return files.get(id);},async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>a<b?-1:a>b?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}};
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}),async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};},async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},canonical:request=>receipts.get(request.id)};
// commit 在同一替身调用中处理文件和镜像;scanFiles 必须匹配调用者捕获的 rootRevision,并按文件 ID 分页。
const storage={fileDeletion:'atomic-v1',
// 只允许当前引擎持有文件仓库;释放时同时清除 owner 和 active 标记。
async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;},
// 返回克隆恢复镜像,文件字节由独立 files 仓库保留。
async recover(){return image?structuredClone(image):null;},
// 校验 owner,按批次写入/删除字节后保存镜像;记录整个 batch 供清理断言观察。
async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};},
// 读取本地 Blob;用例可包装这个方法在真实返回前挂起。
async readFile(leader,id){assert.equal(leader,owner);return files.get(id);},
// 只在指定持久化修订上盘点;排序后取最多 limit 项,用 nextAfter 表示尚有后页。
async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>a<b?-1:a>b?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}};
// 普通文档编辑通过文档端口发送并处理规范回执;清理本身不应该产生任何网络文档请求。
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}),
// 应用普通文档修改并保存该请求的规范模型;资源清理场景通过 requests 检查未误入此路径。
async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};},
// 只返回此替身保存过的请求结果;没有记录则保持 unresolved。
async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},
// 根据请求取 ACK 基线,供无关编辑的正常同步使用。
canonical:request=>receipts.get(request.id)};
// 复用同一文件仓库与存储镜像以支持重开;advance 只推进保留期墙上时间,网络重试仍使用真实定时器。
return{files,batches,requests,storage,advance:ms=>{now+=ms;},image:()=>image,open:()=>openProjectEngine({scope,registry,document,versions:new Map([['C',1]]),storage,persistence:port,createId:()=>`id-${++next}`,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}})};
}
// 只读盘点按 limit 分页且保持 Root 引用;未登记但被元素引用的文件受保护,只有无引用文件经标记和保留期后删除,文档/history 不变。
test('inventory is bounded and read-only; unregistered but referenced files stay protected',async()=>{
const doc=createDocument('p',[{id:'C',parent:null}],[node('A',{assetId:'used'})]),f=fixture(new Map([['old',new Blob(['old'])],['used',new Blob(['used'])]]),doc),engine=await f.open();try{await engine.persist();const root=engine.getSnapshot(),first=await engine.inventoryLocalFiles({limit:1});assert.equal(engine.getSnapshot(),root);assert.equal(first.files[0].id,'old');assert.equal(first.files[0].ownership,'unregistered');assert.equal(first.nextAfter,'old');const second=await engine.inventoryLocalFiles({after:first.nextAfter,limit:1});assert.equal(second.files[0].id,'used');assert.ok(second.files[0].pins.some(owner=>owner.startsWith('element:')));assert.equal(second.nextAfter,undefined);assert.equal(f.requests.length,0);
const marked=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(marked.markedIds,['old']);assert.deepEqual(marked.purgedIds,[]);assert.equal(f.image().formatVersion,5);f.advance(100);const result=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(result.purgedIds,['old']);assert.equal(f.files.has('old'),false);assert.equal(f.files.has('used'),true);assert.equal(engine.getSnapshot().document,root.document);assert.equal(engine.getSnapshot().history,root.history);assert.equal(f.requests.length,0);decodeRecovery(f.image(),registry);
}finally{await engine.close();}
});
// 退休文件及 v5 镜像重开后仍禁止显示或新增引用,也不能通过清空 orphanFiles 抹去退休证据;无关编辑继续可用,静默降级镜像被拒绝。
test('retired orphan identity and v5 recovery survive reopening and reject new references or silent downgrade',async()=>{
const f=fixture(new Map([['old',new Blob(['old'])]]));let engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:0});const file=engine.getSnapshot().runtime.orphanFiles.get('old');assert.ok(file.retirement.purgedAt!==undefined);for(const formatVersion of [1,2,3,4])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);await engine.close();engine=await f.open();await assert.rejects(engine.displayResource('old'),/local_file_retired/);assert.equal(engine.editor.dispatch({type:'element.create',element:node('bad',{assetId:'old'})}).code,'local_file_retired');assert.equal(engine.editor.updateRuntime(previous=>({...previous,orphanFiles:new Map()})).code,'local_file_retired');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.files.has('old'),false);
}finally{await engine.close();}
});
// 文件大小与 MIME 相同也可能换了字节;digest 改变必须重置无引用起点,重新等待完整保留期才能删除。
test('changed orphan bytes reset the retention interval even when size and MIME stay equal',async()=>{
const f=fixture(new Map([['old',new Blob(['aa'])]])),engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:100});const digest=engine.getSnapshot().runtime.orphanFiles.get('old').digest;f.advance(100);f.files.set('old',new Blob(['bb']));const changed=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(changed.changedIds,['old']);assert.deepEqual(changed.purgedIds,[]);const record=engine.getSnapshot().runtime.orphanFiles.get('old');assert.notEqual(record.digest,digest);assert.equal(record.retirement.unreferencedAt,1100);f.advance(100);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,['old']);}finally{await engine.close();}
});
// 显示读取一开始即借用并清除软退休标记,不必等 URL 返回;释放后可重新计时,显式 resourcePins 再次阻止删除。
test('display borrowing and explicit owners reset soft markers before collection',async()=>{
const f=fixture(new Map([['old',new Blob(['data'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const opening=engine.displayResource('old');await entered.promise;assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await opening;handle.release();await engine.collectOrphanFiles({retentionMs:100});engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['export',['old']]])}));assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);}finally{gate.resolve();await engine.close();}
});
// 哈希字节预算耗尽要保留 nextAfter 继续扫描;读字节期间 Root 改变会使验证过期,此轮不能删除任何文件。
test('hash budgets preserve pagination progress and a changed Root invalidates read-only verification',async()=>{
const f=fixture(new Map([['a',new Blob(['a'])],['b',new Blob(['bb'])],['c',new Blob(['c'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{const first=await engine.collectOrphanFiles({retentionMs:100,maxHashBytes:1});assert.deepEqual(first.markedIds,['a']);assert.equal(first.nextAfter,'a');assert.equal(first.hashBudgetExhausted,true);assert.equal(first.scanFinished,false);const second=await engine.collectOrphanFiles({retentionMs:100,after:first.nextAfter,maxHashBytes:3});assert.deepEqual(second.markedIds,['b','c']);assert.equal(second.scanFinished,true);
f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const collecting=engine.collectOrphanFiles({retentionMs:0});await entered.promise;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'changed while hashing'}}).status,'applied');gate.resolve();await assert.rejects(collecting,/stale_file_inventory/);assert.equal(f.files.size,3);
}finally{gate.resolve();await engine.close();}
});
// abort 后迟到字节不得发布盘点结果;删除批次保存失败时保留原清理切点及资源访问屏障,persist 重试完成同一次删除。
test('cancelling verification never publishes late byte results and failures retain the original purge cut',async()=>{
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile,cancel=new AbortController();let fail=false;const commit=f.storage.commit;try{f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const task=engine.collectOrphanFiles({retentionMs:0,signal:cancel.signal});await entered.promise;cancel.abort();await assert.rejects(task,/file_inventory_aborted/);gate.resolve();await Promise.resolve();assert.equal(engine.getSnapshot().runtime.orphanFiles?.size??0,0);f.storage.readFile=read;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectOrphanFiles({retentionMs:0}),/disk failure/);assert.ok(f.files.has('old'));await assert.rejects(engine.displayResource('old'),/resource_collection_in_progress/);fail=false;await engine.persist();assert.equal(f.files.has('old'),false);assert.ok(decodeRecovery(f.image(),registry).runtime.orphanFiles.get('old').retirement.purgedAt!==undefined);
}finally{fail=false;gate.resolve();await engine.close();}
});
// 运行任务、恢复审计、未完成导入和复制准入都是不透明生产者;即使已知资源表为空,引用覆盖仍不完整,不能据此回收。
test('opaque producers block orphan collection even when the known local resource map is empty',async()=>{
const f=fixture(),engine=await f.open();try{const root=engine.getSnapshot(),coverage=runtime=>orphanCoverage({...root,runtime},registry,new Map(),new Map());assert.equal(coverage({...root.runtime,runs:new Map([['run',{id:'run'}]])}).complete,false);assert.ok(coverage({...root.runtime,recoveries:new Map([['audit',{}]])}).blockers.includes('recovery_audits'));assert.ok(coverage({...root.runtime,imports:new Map([['job',{id:'job',status:'paused',source:{fileId:'source'}}]])}).blockers.includes('unfinished_imports'));assert.ok(orphanCoverage(root,registry,new Map(),new Map([['copy-admission',[]]])).blockers.includes('copy_admission'));
}finally{await engine.close();}
});
// 复制准入在 ImportJob 出现前已构成保护;即使调用带已取消 signal,也保守清除软退休标记,下一次扫描从当前时间重新计时。
test('a cancelled copy admission resets orphan retention before any ImportJob is published',async()=>{
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),cancel=new AbortController();try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);cancel.abort();await assert.rejects(engine.imports.duplicate([{id:'A',incarnation:1}],{signal:cancel.signal}),/copy_aborted/);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,[]);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement.unreferencedAt,1100);}finally{await engine.close();}
});
+7
View File
@@ -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';
/** 默认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};
// 两个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);});
// 同注册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();});
// 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);});
+26 -4
View File
@@ -1,33 +1,55 @@
/** Imports compiled ESM directly: no TypeScript stripping or application bundler. */
/**
* 浏览器直接消费 /package 的编译 ESM:不依赖 TypeScript stripping 或应用 bundler。
* 真实测试 module Worker、PNG 解码、SHA-256 及浏览器存储恢复;网络显式阻止,不验证后端写入。
*/
import {createRegistry,builtinComponents,createDocument} from '/package/modules/canvas-core/index.js';
import {openProjectEngine} from '/package/modules/canvas-runtime/index.js';
import {createFoundryPersistence} from '/package/modules/canvas-service/index.js';
import {createBrowserStorage,createBrowserDigests,createBrowserImageDecoder} from '/package/modules/canvas-runtime/browser.js';
// 由浏览器检查入口调用并返回已通过场景的文字;所有断言在实际浏览器能力上执行。
export async function run(){
// 最小断言在页面端抛错,由外层 runner 读取失败;results 只有对应断言完成后才追加。
const assert=(condition,message)=>{if(!condition)throw Error(message);},results=[];
// 每次使用随机数据库名,创建生产 storage、digest 和 image 适配器,避免复用其他测试数据库。
const name=`headless-package-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),digests=createBrowserDigests(),images=createBrowserImageDecoder();
// 独立随机项目和空文档基线;重开时仍使用这组 scope 和同一 storage。
const scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:crypto.randomUUID()},registry=createRegistry(builtinComponents()),document=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
// 记录 Worker URL 和活跃数;保存原生构造器以便清理时恢复全局对象。
const workerUrls=[],NativeWorker=globalThis.Worker;let active=0;
globalThis.Worker=class extends NativeWorker{constructor(url,...args){super(url,...args);workerUrls.push(String(url));active++;this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
// 包装真实 Worker 而非模拟消息:构造成功后计数,terminate 只扣减一次,仍调用原生终止方法。
globalThis.Worker=class extends NativeWorker{
// 先完成原生构造,再记录 URL 与活跃计数;保留正常 Worker 消息和错误行为。
constructor(url,...args){super(url,...args);workerUrls.push(String(url));active++;this.live=true;}
// 同一实例多次终止只扣减一次计数,避免负数掩盖泄漏;每次仍调用原生 terminate。
terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
let engine;
try{
// 用 OffscreenCanvas 生成真实 4×2 PNG,检查编译图片 Worker 的尺寸结果。
const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});
const dimensions=await images.decode(bytes);assert(dimensions.width===4&&dimensions.height===2,'compiled image Worker dimensions');
// 将编译哈希 Worker 结果与浏览器原生 SHA-256 对照,不使用预设摘要跳过实际字节读取。
const digest=await digests.digest(bytes),expected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join('');assert(digest===expected,'compiled digest Worker SHA256');
// Worker 必须请求发布的 .js 文件,任务结束后活跃数为零;这验证 URL 布局和正常结束时的释放。
assert(workerUrls.some(url=>url.endsWith('/image.worker.js'))&&workerUrls.some(url=>url.endsWith('/digest.worker.js')),'Worker URLs did not resolve emitted JS');assert(active===0,'compiled Workers leaked');
results.push('compiled image and digest module Workers load emitted JS, decode a real PNG, match native SHA256 and terminate');
// 再验证 16 MiB 数据摘要及释放;耗时仅记为本机观察值,没有性能 SLA 断言。
const largeBytes=new Blob([new Uint8Array(16*1024*1024).fill(97)]),largeExpected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await largeBytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join(''),started=performance.now(),largeDigest=await digests.digest(largeBytes),digestMs=performance.now()-started;assert(largeDigest===largeExpected&&active===0,'large compiled digest returned wrong bytes or retained its Worker');results.push('16 MiB compiled digest Worker matched SHA256 in '+digestMs.toFixed(1)+' ms (observation, not an SLA)');
// Keep networking explicitly held: verify the actual browser durability adapter
// independently of an invented backend protocol implementation.
// 显式禁止网络派发,单独验证实际浏览器持久化适配器,避免引入虚构后端协议。
// 借用真实编译端口但声明不支持任何请求,因此编辑只保存在本地;若意外进入网络就直接失败。
const persistence={...createFoundryPersistence({registry,rootId:scope.projectId,workspaceId:scope.workspaceId,transport:async()=>{throw Error('unexpected network');}}),supportsRequest:()=>false};
// 以相同 scope 重新装配引擎,触发真实浏览器存储的 owner 申请和镜像恢复。
const open=()=>openProjectEngine({scope,registry,document,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:()=>crypto.randomUUID()});
engine=await open();
// 创建一次文本元素和一个临时视图,前者及历史应恢复,后者必须随旧会话消失。
const target={id:crypto.randomUUID(),incarnation:1};
assert(engine.editor.dispatch({type:'element.create',element:{...target,ownerCanvasId:scope.projectId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Packed recovery',text:'persisted'},geometry:{x:12,y:24,width:160,height:80,rotation:0}}}).status==='applied','compiled command rejected');
engine.sessions.attach('temporary-view');
// 等待本地保存后关闭/重开,检查修订不回退、文档和一个 undo 单元保留、views 为空。
await engine.settle();const revision=engine.getSnapshot().rootRevision;await engine.close();engine=await open();
assert(engine.getSnapshot().rootRevision>=revision,'compiled IndexedDB recovery revision regressed');assert(engine.getSnapshot().document.elements.get(target.id)?.data.text==='persisted','compiled durable document lost');assert(engine.getSnapshot().history.undo.length===1,'compiled durable history lost');assert(engine.sessions.getSnapshot().views.size===0,'transient sessions persisted');await engine.settle();
results.push('compiled browser storage and engine claim/release Web Locks and recover an edited document/history from IndexedDB without persisting views');
// 仅在全部 Worker 和存储断言通过后返回场景结果。
return results;
// 清理当前引擎和适配器,恢复原生 Worker,并发起删除测试数据库;这里不等待 deleteDatabase 的完成事件。
}finally{await engine?.close();images.close();digests.close();storage.close();globalThis.Worker=NativeWorker;indexedDB.deleteDatabase(name);}
}
+30 -4
View File
@@ -1,3 +1,8 @@
/**
* 发布包消费测试入口。先执行 build-headless 生成 .headless-dist,再离线 npm pack 到临时目录,从解包产物验证 Node、类型和浏览器入口。
* 执行会触发编译/打包及浏览器检查,不是 node:test 的轻量单测;最后清理临时消费者目录,构建目录仍由构建脚本管理。
* 内嵌模板是待生成的独立消费者源码,注释在各模板外说明,避免改变测试输入本身。
*/
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, readFile, realpath, rm, writeFile, mkdir, symlink } from 'node:fs/promises';
@@ -5,17 +10,25 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// 以脚本路径定位仓库根,避免调用者 cwd 不同导致打包错误目录。
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
// 临时目录先解析真实路径,后面的包内路径守卫据此判定 import 是否越出解包根。
const temporary=await realpath(await mkdtemp(join(tmpdir(),'endoleap-headless-package-')));
// 同步运行子步骤,默认在仓库根且捕获输出;options 可指定消费者 cwd 和独立 npm cache,失败交给外层统一报告。
const run=(program,args,options={})=>execFileSync(program,args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe'],...options});
try {
// 先编译真正发布产物;后面的测试都消费该产物,不能用源文件 TypeScript stripping 冒充包可用。
run(process.execPath,['scripts/build-headless.mjs']);
// npm pack 禁用生命周期脚本、使用离线模式并写入临时目录;这验证已有构建产物的包装,不触发额外安装。
const packed=JSON.parse(run('npm',['pack','--offline','--ignore-scripts','--json','--pack-destination',temporary],{cwd:join(root,'.headless-dist'),env:{...process.env,npm_config_cache:join(temporary,'npm-cache')}}));
assert.equal(packed.length,1);
// 解包唯一 tarball,后续 packageRoot 代表实际发布文件布局。
run('tar',['-xzf',join(temporary,packed[0].filename),'-C',temporary]);
const packageRoot=join(temporary,'package');
// 读取打包后的 package.json 与 README,检查私有 ESM 包、无直接运行时依赖,以及可选框架 peer 元数据。
const metadata=JSON.parse(await readFile(join(packageRoot,'package.json'),'utf8'));
const readme=await readFile(join(packageRoot,'README.md'),'utf8');
// 逐个检查 README 的相对 .md 链接可在包内读取且非空;外部 URL 不在此处做网络可达性验证。
for(const match of readme.matchAll(/\]\(([^)]+\.md)\)/g)){
if(/^[a-z]+:/i.test(match[1]))continue;
assert.ok((await readFile(join(packageRoot,match[1]),'utf8')).length>0,`packed README link is empty: ${match[1]}`);
@@ -23,7 +36,8 @@ try {
assert.equal(metadata.private,true);assert.equal(metadata.type,'module');
assert.equal(Object.keys(metadata.dependencies??{}).length,0);
assert.deepEqual(Object.keys(metadata.peerDependencies??{}).sort(),['@xyflow/react','react','react-dom']);assert.ok(Object.values(metadata.peerDependenciesMeta).every(value=>value.optional===true));
// Resolve every import through the packed artifact, never checkout node_modules.
// portable 消费者的非内建 import 必须来自解包产物,不能偷偷借用仓库 node_modules。
// 生成 ESM resolve 钩子:node: 内建模块放行,其他解析结果必须是 packageRoot 内的 file URL。
await writeFile(join(temporary,'guard-loader.mjs'),`
import {fileURLToPath} from 'node:url';
import {sep} from 'node:path';
@@ -35,14 +49,17 @@ try {
return result;
}
`);
// 生成 Node 启动守卫:注册路径钩子,并将 window/document/navigator/IndexedDB/Worker 读取设为抛错 getter。
await writeFile(join(temporary,'guard.mjs'),`
import {register} from 'node:module';
register('./guard-loader.mjs',import.meta.url);
for(const name of ['window','document','navigator','indexedDB','Worker'])Object.defineProperty(globalThis,name,{configurable:true,get(){throw Error('Browser global: '+name);}});
`);
// 在独立进程运行打包 consumer.mjs;portable、重开和元素数由消费者真实执行结果断言,不从源码文本推断。
const report=JSON.parse(run(process.execPath,['--import',join(temporary,'guard.mjs'),join(packageRoot,'consumer.mjs')],{cwd:temporary}));
assert.equal(report.status,'ok');assert.equal(report.portableBindings,true);assert.equal(report.reopened,true);assert.equal(report.elements,2);
// No DOM library: browser adapter declarations must not leak into portable ports.
// 不加载 DOM 类型库,验证 portable 端口的公开声明没有泄漏浏览器专属类型。
// 生成不带 DOM 的 TS 消费者:检查 loading v2-v6、导入可用性、部分查询、场景投影/命中和 StoragePort 装配的公开类型。
await writeFile(join(packageRoot,'consumer-types.mts'),`
import {createRegistry,builtinComponents,createDocument} from '@endoleap/headless-canvas/core';
import {openProjectEngine,type StoragePort,type StorageScope,type LoadingState} from '@endoleap/headless-canvas/runtime';
@@ -71,13 +88,15 @@ try {
return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})});
}
`);
// 仅使用 ES2022 与 Node 类型进行 noEmit 检查,防止 portable 端口意外依赖浏览器声明。
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022','--types','node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'consumer-types.mts')],{cwd:temporary});
// A separate consumer opts into framework peers. The portable guard above
// remains strict and has already proved /bindings does not resolve any peers.
// 框架 peers 由第二个消费者显式接入;上面的路径守卫保持严格,/bindings 不能依赖这些 peers。
// 另一个显式启用框架的消费者才连接 React/ReactFlow peers,前面的严格 portable 检查已独立完成。
await mkdir(join(packageRoot,'node_modules/@xyflow'),{recursive:true});
for(const peer of ['react','react-dom','@xyflow/react'])await symlink(await realpath(join(root,'node_modules',peer)),join(packageRoot,'node_modules',peer),'dir');
await mkdir(join(packageRoot,'node_modules/@types'),{recursive:true});
for(const peer of ['react','react-dom'])await symlink(await realpath(join(root,'node_modules/@types',peer)),join(packageRoot,'node_modules/@types',peer),'dir');
// 生成 SSR 消费者:HookView 读取公开 hooks,View 取得 renderer 命令 context;实际渲染并派发一次命令检查绑定。
await writeFile(join(packageRoot,'react-consumer.mjs'),`
import assert from 'node:assert/strict';
import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';
@@ -93,7 +112,9 @@ try {
const html=renderToStaticMarkup(React.createElement(CanvasRendererCommandsContext.Provider,{value:commands},React.createElement(renderers.nodeTypes.fixture)));
assert.equal(html,'<span>peer renderer</span>');assert.strictEqual(seen,commands);seen.dispatch({type:'element.data',target:{id:'fixture',incarnation:1},patch:{title:'command'}});assert.equal(dispatched,1);console.log('PASS explicit React peer renderer/context consumer');
`);
// 运行实际解包后的 React 入口,显示该独立消费者输出。
const react=run(process.execPath,[join(packageRoot,'react-consumer.mjs')],{cwd:temporary});process.stdout.write(react);
// 生成框架 TS 消费者:Hooks 检查选择器、actions 和资源/操作视图,Card/Link 分别验证公开节点及边投影类型。
await writeFile(join(packageRoot,'react-consumer-types.mts'),`
import {createElement} from 'react';import type {NodeProps,EdgeProps} from '@xyflow/react';
import {createReactRenderers,type ReactFlowProjectionNode,type ReactFlowProjectionEdge} from '@endoleap/headless-canvas/react-flow';
@@ -105,15 +126,20 @@ try {
function Link({data}:EdgeProps<ReactFlowProjectionEdge>){return createElement('span',null,data?.__headlessRecord?.id);}
export const adapter=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'card',render:Card},{componentId:'connection',dataVersion:1,structure:'connection',rendererId:'link',render:Link}]);
`);
// 框架消费者单独启用 DOM 和 React 类型;这不能替代前面不带 DOM 的 portable 检查。
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022,DOM,DOM.Iterable','--types','react,react-dom,node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'react-consumer-types.mts')],{cwd:temporary});
// 继续执行浏览器包消费场景,直接加载编译 ESM 与 Worker、IndexedDB/Web Locks,独立验证浏览器适配器。
const browser=run(process.execPath,['tests/headless/browser-storage.mjs','--package']);
process.stdout.write(browser);
// 只有所有消费和类型检查完成后才输出整体报告;packedFiles 取自实际 npm pack 结果。
console.log(JSON.stringify({relativeDocumentation:true,compiledBrowserAdapters:true,portableBindings:true,explicitReactPeers:true,status:'ok',packedFiles:packed[0].files.length,consumer:report,nodeTypes:true,browserGlobalsBlocked:true,externalRuntimeDependenciesBlocked:true}));
// 保留子进程 stdout/stderr 便于定位失败,再抛出原错误使入口失败。
} catch(error){
if(error.stdout)process.stderr.write(error.stdout);
if(error.stderr)process.stderr.write(error.stderr);
throw error;
} finally {
// 成功或失败都删除临时消费者目录;不在这里删除仓库的 .headless-dist。
await rm(temporary,{recursive:true,force:true});
}
+13
View File
@@ -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';
/** 默认10×10区域,参数可构造边界接触、移动、巨型覆盖及非法矩形。 */
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);});
// 整批含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}));});
import {projectedPaintOverflow} from '../../src/lib/canvas-bindings/projected-paint.ts';
import {createRegistry,createDocument,builtinComponents} from '../../src/lib/canvas-core/index.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);});
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();});
// 仅声明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);});
// 1000节点反复同步不重复捕获依赖;单节点位置改动捕获1项,全局变换改动捕获全部。
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);
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',()=>{
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);
}
});
// 插入renderer-unavailable标记后改用占位绘制规则,不继续用原组件外扩范围;移除标记恢复,范围缓存须失效。
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'));
});
+6
View File
@@ -1,4 +1,10 @@
/**
* 指针捕获辅助函数的对象替身测试,不分派真实 DOM PointerEvent。
* 指针 ID 与捕获目标共同决定归属,旧 lostpointercapture 不能取消新目标或重新捕获后的手势。
*/
import {test} from 'node:test';import assert from 'node:assert/strict';
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from '../../src/lib/canvas-bindings/pointer-capture.ts';
// move/up/cancel 检查 pointerId;lostpointercapture 还检查事件 target 与当前捕获状态,空 owner 不匹配。
test('pointer move/up/cancel only match their owner and old lost-capture cannot cancel a replacement target',()=>{const capture={},other={},owner={pointerId:7,capture};for(const type of ['pointermove','pointerup','pointercancel']){assert.equal(ownsPointerCapture(owner,{pointerId:8,type}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type}),true);}assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:other}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:capture}),true);assert.equal(ownsPointerCapture(null,{pointerId:7}),false);const recaptured={hasPointerCapture:()=>true};assert.equal(ownsPointerCapture({pointerId:7,capture:recaptured},{pointerId:7,type:'lostpointercapture',target:recaptured}),false);});
// 成功捕获只释放一次;节点脱离导致的 set/hasPointerCapture 异常作为捕获失败或安全清理处理,不升级为业务错误。
test('failed capture and detached release stay transport failures, while a held capture releases once',()=>{let held=false,releases=0;const target={setPointerCapture(){held=true;},hasPointerCapture(){return held;},releasePointerCapture(){held=false;releases++;}};assert.equal(acquirePointerCapture(target,1),true);releasePointerCapture(target,1);releasePointerCapture(target,1);assert.equal(releases,1);assert.equal(acquirePointerCapture({...target,setPointerCapture(){throw Error('detached');}},2),false);assert.doesNotThrow(()=>releasePointerCapture({...target,hasPointerCapture(){throw Error('detached');}},2));});
+14
View File
@@ -1,33 +1,47 @@
/**
* ReactFlow投影对象/数组复用的正确性测试。用引用断言区分无关变化、实际数据/选择/测量变化及owner/生命周期替换。
* 缓存必须保留最新document来源,失败准备不能部分替换已发布节点表。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.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]);
/** 先完整投影,再交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));
// 增加C或ACK等价克隆不替换A/B/edge;变换表可新建,不能据此强制所有元素重挂载。
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 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',()=>{
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 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');
});
// incarnation或scope变化不复用;移入child即使场景position相同也必须换对象,删除/clear后不能复活旧缓存。
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 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]);
});
// 平移不改变节点内部测量可保留,端口/子图scale或作用域变更不可沿用;clear后getNode为空。
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 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 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]);});
// 节点顺序反转必须换数组并按新顺序返回;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);});
// 新投影边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');});
// 新节点包络只多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);});
+29 -2
View File
@@ -1,4 +1,8 @@
/** Compile and exercise the actual Next route in a disposable frontend project. */
/**
* 在临时前端项目中编译实际 Next API route,连接本地 HTTP 上游替身,检查真实代理路径。
* 执行本脚本会启动 Next dev 并产生构建文件;不是纯静态检查,也不是远程 Foundry 数据库集成。
* 覆盖冻结字节、认证头、资产元数据/上传、AI 协议与事件游标、流式首块,以及路径 allowlist。
*/
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, mkdir, copyFile, writeFile, symlink, rm } from 'node:fs/promises';
@@ -7,11 +11,17 @@ import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { setTimeout as pause } from 'node:timers/promises';
// 临时目录只复制代理必需文件,received 保存上游实际收到的请求,避免只验证前端 fetch 参数。
const source=resolve(import.meta.dirname,'../..'),directory=await mkdtemp(join(tmpdir(),'endoleap-proxy-check-')),received=[];
// 测试专用账户和幂等键都是固定假值,不读取真实用户登录凭据。
const account='10000000-0000-4000-8000-000000000004',key='90000000-0000-4000-8000-000000000002';
// 响应特意保留空白和 extension,要求代理把终态回执原字节透传而非重建 JSON。
const responseBytes=' { "protocol_version":2, "request_id":"'+key+'", "status":"not-applied", "terminal":true, "error":"fixture_rejection", "extension":{"retained":true} }\n';
// 流尾由测试释放,首块可先到客户端;用来判断代理是否缓冲了整条上游响应。
let streamRelease;const streamGate=new Promise(resolve=>streamRelease=resolve);
// 仅用于代理传输的最小 NDJSON 片段,不是完整模型解码样本。
const streamPrefix='{"streamVersion":1,"kind":"header"}\n',streamTail='{"streamVersion":1,"kind":"complete"}\n';
// 上游先收集 body 和 headers;模型流先写前缀再等待,其余路径返回可辨识 JSON 或 409 回执。
const upstream=createServer(async(request,response)=>{
const chunks=[];for await(const chunk of request)chunks.push(chunk);
received.push({path:request.url,body:Buffer.concat(chunks),headers:request.headers});
@@ -19,18 +29,25 @@ const upstream=createServer(async(request,response)=>{
response.writeHead(request.url.endsWith('/model/operations')?409:200,{'Content-Type':'application/json'});
response.end(request.url.endsWith('/model/operations')?responseBytes:'{"id":"fixture"}');
});
// 系统分配上游监听端口;另一个短暂监听器只为 Next 选端口,释放后再启动 Next,未构成持续端口锁。
await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve));
const reservation=createServer();await new Promise(resolve=>reservation.listen(0,'127.0.0.1',resolve));const port=reservation.address().port;await new Promise(resolve=>reservation.close(resolve));
// 保存子进程、有限长度启动日志和 spawn 错误,供 readiness 失败时定位原因。
let next,logs='',launchError;
try{
// 复制实际 route 及它依赖的响应/会话模块;临时页面仅满足 Next 项目结构,不替换被测 route。
for(const filename of ['package.json','tsconfig.json','src/app/api/foundry/[...path]/route.ts','src/lib/canvas-response.ts','src/lib/session-cookie.ts']){
const target=join(directory,filename);await mkdir(resolve(target,'..'),{recursive:true});await copyFile(join(source,filename),target);
}
// 复用当前依赖树而不重新安装,临时项目的源码和编译目录与主项目分开。
await symlink(join(source,'node_modules'),join(directory,'node_modules'));
await writeFile(join(directory,'src/app/layout.tsx'),'export default function Layout({children}:{children:React.ReactNode}){return <html><body>{children}</body></html>}');
await writeFile(join(directory,'src/app/page.tsx'),'export default function Page(){return <main>Proxy verification fixture</main>}');
// 显式运行 webpack 开发服务器并指向本地上游,detached 便于 finally 按进程组清理。
next=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),'dev','--webpack','--hostname','127.0.0.1','--port',String(port)],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'fixture'},stdio:['ignore','pipe','pipe']});
// 只保留最后 12000 字符日志;启动失败和退出必须被 readiness 循环识别。
next.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.on('error',error=>{launchError=error});
// 最多 90 次探测,单次请求超时 1.5 秒、失败间隔 250 毫秒;只有实际代理返回 200 才开始断言。
let ready=false;
for(let attempt=0;attempt<90;attempt++){
if(launchError)throw launchError;if(next.exitCode!==null)throw new Error('Next exited during startup:\n'+logs);
@@ -38,22 +55,28 @@ try{
await pause(250);
}
if(!ready)throw new Error('Next did not become ready:\n'+logs);
// 规范文档操作路径使用固定 workspace/material,后续从它推导资产及 AI 路由。
const path='workspaces/10000000-0000-4000-8000-000000000003/materials/10000000-0000-4000-8000-000000000001/canvas/model/operations';
// More than the generic 1 MiB proxy cap, below the canonical 5 MiB cap.
// 1.1 MB 请求超过通用 1 MiB 上限但低于规范文档专用 5 MiB 上限,专门覆盖该路由的体积策略。
const body=' {\n "protocol_version":2,"request_id":"'+key+'","fixture":"'+ 'x'.repeat(1100000)+'"\n} ';
// 发送超过通用限制但低于文档专用限制的原始 body,核对上游状态码及终态回执字节不变。
const response=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}`,{method:'POST',body,headers:{'Content-Type':'application/json','Idempotency-Key':key,'X-Expected-Account':account,Cookie:'foundry_session=fixture-session',Origin:`http://127.0.0.1:${port}`},signal:AbortSignal.timeout(20000)});
assert.equal(response.status,409);assert.equal(await response.text(),responseBytes);
// 从上游记录检查 body、幂等键、预期账户,以及 session cookie 转成 Bearer 的实际结果。
const request=received.find(item=>item.path.endsWith('/model/operations'));
assert.ok(request);assert.equal(request.body.toString(),body);assert.equal(request.headers['idempotency-key'],key);assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers.authorization,'Bearer fixture-session');
// 资产回执和元数据是 JSON 路径;元数据还检查身份传递与 no-store,避免误走图片 body 验证。
const receiptPath=`workspaces/10000000-0000-4000-8000-000000000003/assets/operations/${key}`;
const assetReceipt=await fetch(`http://127.0.0.1:${port}/api/foundry/${receiptPath}`);assert.equal(assetReceipt.status,200);assert.deepEqual(await assetReceipt.json(),{id:'fixture'});
const metadata=await fetch(`http://127.0.0.1:${port}/api/foundry/${receiptPath.replace('/operations/','/metadata/')}`,{headers:{'X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});assert.equal(metadata.status,200);assert.deepEqual(await metadata.json(),{id:'fixture'});assert.match(metadata.headers.get('cache-control'),/no-store/);const metadataRequest=received.find(item=>item.path.includes('/assets/metadata/'));assert.equal(metadataRequest.headers['x-expected-account'],account);assert.equal(metadataRequest.headers.authorization,'Bearer fixture-session');
console.log('PASS asset metadata JSON uses the actual authenticated Next proxy without image-body validation or caching');
// FormData 自带 boundary,检查代理保留 multipart 字节及 X-Resource-Id,而不自行重新编码文件。
const multipart=new FormData();multipart.append('image',new Blob(['immutable file'],{type:'image/png'}),'fixture.png');
const assetPath=receiptPath.split('/operations/')[0],resourceId='10000000-0000-4000-8000-000000000006';
const uploaded=await fetch(`http://127.0.0.1:${port}/api/foundry/${assetPath}`,{method:'POST',body:multipart,headers:{'X-Resource-Id':resourceId,'Idempotency-Key':key,'X-Expected-Account':account}});await uploaded.text();assert.equal(uploaded.status,200);
const upload=received.find(item=>item.path.endsWith('/assets'));assert.equal(upload.headers['x-resource-id'],resourceId);assert.equal(upload.headers['idempotency-key'],key);assert.match(upload.body.toString(),/immutable file/);assert.match(upload.headers['content-type'],/^multipart\/form-data; boundary=/);
console.log('PASS asset receipt JSON and multipart resource identity cross the actual Next proxy');
// AI 受理同样保留冻结 JSON 空白、幂等键和 If-Match,并协商 canvas v2;查询回执与事件游标单独检查。
const aiPath=path.replace('/model/operations','/ai/runs');
const frozenAI=' {\n "protocol_version":2,"request_id":"'+key+'","prompt":"保留请求字节" } ';
const aiResponse=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}`,{method:'POST',body:frozenAI,headers:{'Content-Type':'application/json','Idempotency-Key':key,'If-Match':'"7"','X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});
@@ -65,15 +88,19 @@ try{
const aiEvents=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}/${key}?after_seq=41&limit=100`);await aiEvents.text();assert.equal(aiEvents.status,200);
assert.ok(received.some(item=>item.path.endsWith('/ai/runs/'+key+'?limit=100&after_seq=41')));
console.log('PASS frozen AI bytes, identity, version header, admission receipt and event cursor cross the actual Next proxy');
// 上游尾部仍挂起时读取第一块,随后释放并核对余下字节,验证端到端增量转发。
const streamed=await fetch(`http://127.0.0.1:${port}/api/foundry/${path.replace('/operations','')}`,{headers:{Accept:'application/vnd.foundry.canvas-model+ndjson;version=1'}});
assert.equal(streamed.headers.get('content-type'),'application/vnd.foundry.canvas-model+ndjson;version=1');
const reader=streamed.body.getReader(),first=await reader.read();assert.equal(new TextDecoder().decode(first.value),streamPrefix);streamRelease();let rest='';while(true){const next=await reader.read();if(next.done)break;rest+=new TextDecoder().decode(next.value);}assert.equal(rest,streamTail);reader.releaseLock();
console.log('PASS negotiated canonical NDJSON and first chunk cross the actual Next proxy before the gated tail');
// 多余路径段应在代理 allowlist 被拒绝,不能发到上游再依赖上游返回错误。
const before=received.length,invalid=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}/unexpected/extra`,{signal:AbortSignal.timeout(10000)});await invalid.text();assert.equal(invalid.status,404);assert.equal(received.length,before);
console.log('PASS actual Next proxy preserves frozen request bytes/key/account and raw terminal receipt above 1 MiB');
console.log('PASS canonical route allowlist rejects unexpected paths without contacting the upstream');
}finally{
// 清理时先放开挂起流,再终止 Next 进程组;超时未退出则强制结束。
streamRelease();
if(next?.pid){const exited=new Promise(resolve=>{if(next.exitCode!==null)resolve();else next.once('exit',resolve)});try{process.kill(-next.pid,'SIGTERM')}catch{}await Promise.race([exited,pause(3000)]);if(next.exitCode===null){try{process.kill(-next.pid,'SIGKILL')}catch{}}}
// 断开上游残留连接并关闭监听,最后删除本次临时项目目录。
upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve));await rm(directory,{recursive:true,force:true,maxRetries:5});
}
+11
View File
@@ -1,13 +1,24 @@
/**
* CommitCoordinator 唯一发布边界测试。业务候选 Root 和 prepareRuntime 的结果必须一起校验、一起发布。
* 拒绝准备或返回 Promise 都不能留下半更新的文档、history、runtime 或查询索引。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
// 固定单元素文档供命令、撤销、重做和规范替换共用,以比较发布前后的引用和订阅次数。
const registry=createRegistry(builtinComponents()),record={id:'A',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'P',data:{title:'A',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}},document=createDocument('project',[{id:'P',parent:null}],[record]);
// 五类合法发布都会先准备 runtime;准备函数随后抛错时,命令、undo、元数据修改和 reconcile 均被拒绝,原 Root 与查询对象不变。
test('coordinator prepares runtime atomically for commands, history, metadata and canonical replacement',()=>{
let nextId=0,reject=false,calls=0;const editor=createEditor({document,registry,runtime:{prepared:0},createId:()=>`op${++nextId}`,prepareRuntime:(previous,next)=>{calls++;assert.equal(previous,editor.getSnapshot());assert.ok(Object.isFrozen(next));if(reject)throw Error('reserved resource');return Object.freeze({...next.runtime,prepared:previous.runtime.prepared+1});}});
// 订阅回调同时检查 runtime 准备序号和 query.document,确保订阅者观察到的是同一发布切点。
const snapshots=[];editor.subscribe((_,snapshot)=>{assert.equal(snapshot.runtime.prepared,snapshots.length+1);assert.equal(editor.getQueries().document,snapshot.document);snapshots.push(snapshot);});
assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'new'}}).status,'applied');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.history.redo().status,'applied');assert.equal(editor.updateRuntime(previous=>({...previous,extra:true})).status,'applied');const source=editor.getSnapshot();assert.equal(editor.reconcile(document,source.runtime,source.rootRevision).status,'applied');assert.equal(calls,5);
// 先保留 Root 和查询引用,再让各入口触发准备失败;不仅比较字段值,还要求未发布任何新对象。
reject=true;const root=editor.getSnapshot(),query=editor.getQueries();for(const operation of [()=>editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'blocked'}}),()=>editor.history.undo(),()=>editor.updateRuntime(()=>({prepared:100})),()=>editor.reconcile(source.document,source.runtime,root.rootRevision)]){assert.equal(operation().status,'rejected');assert.equal(editor.getSnapshot(),root);assert.equal(editor.getQueries(),query);}assert.equal(snapshots.length,5);
});
// 本地发布要求同步完成,Promise 形式的 prepareRuntime 返回 async_transaction,三个入口都不得改变原 Root。
test('an async coordinator cannot publish a partially prepared root',()=>{
const editor=createEditor({document,registry,runtime:0,prepareRuntime:()=>Promise.resolve(1)}),root=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'blocked'}}).code,'async_transaction');assert.equal(editor.updateRuntime(()=>1).code,'async_transaction');assert.equal(editor.reconcile(document,1,root.rootRevision).code,'async_transaction');assert.equal(editor.getSnapshot(),root);
});
+15
View File
@@ -1,23 +1,34 @@
/**
* 文档查询索引的语义与增量更新回归。用父子canvas、跨层连线及声明引用构造关系图,比较增量维护与完整重建结果。
* 空间测试使用确定性几何与计数,不依赖浏览器;complete/opaque覆盖信息必须随同文档发布,不能凭“未查到”推断对象不存在。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,createEditor,createDocumentQueries,advanceDocumentQueries,applyChanges} from '../../src/lib/canvas-core/index.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});
// 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')]);
/** Map<id,Set>规范为排序数组,只消除集合遍历次序差异,不改变实际成员。 */
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());}}
// 检查父子成员、入边和port引用,集合无写方法;未知组件标记引用不完整,缺canvas与更换registry明确拒绝。
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 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',()=>{
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.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',()=>{
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();}}
@@ -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);
});
// 巨大或溢出几何走oversized保守路径;旋转命中检查内外点与角点,非法查询范围拒绝,不把不完整结果说成完整。
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 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',()=>{
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));}
});
// 自定义数组引用去重;改canvas引用移除旧反向关系且复用无关表,声明值变非法后清该主体引用并标opaque。
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);
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',()=>{
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;
+10
View File
@@ -1,8 +1,17 @@
/**
* 查询快照代次的GC保留回归。独立Node进程开启--expose-gc,用WeakRef区分显式持有的旧前缀与已丢弃代次。
* 这是受控强制GC场景,不能据此宣称生产内存没有任何泄漏或保证垃圾回收发生时机。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
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',()=>{
// 转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;
// 嵌入脚本的chain(keep)连续推进30代query,held只在keep=true保留初始查询;WeakRef本身不作为强引用。
// editorChain用真实editor推进同样30次几何,检查当前查询方法是否闭包保留旧document/query。
// 12次setImmediate+gc跨过任务边界后输出布尔存活状态,同时读取显式旧前缀及最新文档的x证明可用性。
const script=`
import {createDocument,createEditor,applyChanges,createRegistry,builtinComponents} from ${JSON.stringify(core)};
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};}
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}));`;
// 子进程限时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});
});
+44
View File
@@ -1,3 +1,8 @@
/**
* 供真实浏览器页面调用的 React hooks 场景。组件挂在隐藏容器中,在 StrictMode 下检查订阅路由、手势租约和资源句柄。
* engine 与 hooks 使用生产实现,存储、上传和文档服务为内存替身;此处不验证远程授权、数据库或图片解码。
* 断言通过页面全局入口返回文字报告,由外层浏览器 runner 收集;不会在组件导入时自动运行全部场景。
*/
"use client";
import {StrictMode,memo,useEffect,useLayoutEffect} from 'react';
import {createRoot} from 'react-dom/client';import {flushSync} from 'react-dom';
@@ -5,54 +10,93 @@ import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBinding
import {createRegistry,builtinComponents,createDocument,applyChanges} from '@/lib/canvas-core/index';
import {openProjectEngine} from '@/lib/canvas-runtime/engine';
import {equal} from '@/lib/canvas-core/values';
// 浏览器断言失败直接抛错,使外层 Runtime.evaluate 得到失败;不把失败写成通过报告。
const assert=(value:unknown,message:string)=>{if(!value)throw Error(message);};
// 短暂等待浏览器计时器;until 最多轮询 1000 次,观察 React effect/订阅结果,而非假设 flush 后同步可读。
const tick=()=>new Promise(resolve=>setTimeout(resolve,5));async function until(check:()=>boolean){for(let i=0;i<1000;i++){if(check())return;await tick();}throw Error('hook observation timed out');}
// 装配两个节点和两个 view;partial 模式只发布 a,但完整来源仍含 b,供 unloaded→present/absent 检查。
async function fixture(partial=false){
// 合法文本节点外壳;版本、身份和 owner 固定,序列号另用于命令/请求 ID。
const node=(id:string)=>({id,incarnation:1,structure:'node' as const,componentId:'text',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:id,text:''}});let server=createDocument(partial?'canvas':'hooks',[{id:'canvas',parent:null}],[node('a'),node('b')]),version=1,serial=0,image:any=null,fail=false,readGate:Promise<void>|undefined;
// 分别保存本地 Blob、文档回执及异步错误,read 次数用于验证资源 hooks 共享读取。
const files=new Map<string,Blob>(),receipts=new Map(),errors:unknown[]=[];let reads=0;
// 内存存储可注入 quota,readFile 可被 Promise 屏障延迟;不实现真实浏览器锁或 IndexedDB。
const storage={async claim(scope:any){return{scope,fence:1,active:true,async release(){this.active=false;}};},async recover(){return image;},async commit(_leader:any,batch:any){if(fail)throw Error('quota');for(const file of batch.files??[])files.set(file.id,file.bytes);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(_leader:any,id:string){reads++;if(readGate)await readGate;return files.get(id);},close(){}};
// 上传替身返回固定 2×1 元数据与资源 URL,不验证图片字节;查询保持 unresolved,accept 直接取 canonical。
const assets={sourceURL:(id:string)=>`https://assets.test/${id}`,displayURL:(id:string)=>`/assets/${id}`,async upload(request:any,bytes:Blob){const payload=JSON.parse(request.body);return{outcome:'applied' as const,canonical:{id:payload.resource_id,url:assets.sourceURL(payload.resource_id),bytes:bytes.size,mime_type:bytes.type,width:2,height:1}};},async query(){return{outcome:'unresolved' as const};},accept:(_request:any,status:any)=>status.canonical};
// 文档替身应用真实 changes 并递增版本,把成功模型放在回执表;不实现真实 HTTP 或请求去重。
const persistence={compile(intent:any,id:string,snapshot:any){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request:any){server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['canvas',++version]])});return{outcome:'applied' as const};},async query(){return{outcome:'unresolved' as const};},canonical:(request:any)=>receipts.get(request.id)};
// 装配真实引擎与 loading 状态,显式 attach v/w 后等待本地初始工作收敛。
const engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:partial?'canvas':'hooks'},document:partial?createDocument('canvas',[{id:'canvas',parent:null}],[node('a')]):server,...(partial?{loading:{version:1 as const,sourceToken:'hook-partial',declaredCanvasIds:['canvas'],receivedElements:1,totalElements:2}}:{}),versions:new Map([['canvas',1]]),registry:createRegistry(builtinComponents()),storage,assets,persistence,createId:()=>`hook-${++serial}`,onError:error=>errors.push(error)});const detachA=engine.sessions.attach('v','canvas'),detachB=engine.sessions.attach('w','canvas');await engine.settle();
// 向场景暴露保存故障、读取屏障与读次数;detach 只清理 fixture 自己附加的两个会话。
return{engine,node,files,errors,full:server,get reads(){return reads;},fail(value:boolean){fail=value;},gate(value?:Promise<void>){readGate=value;},detach(){detachA();detachB();}};
}
// 创建隐藏 React root 并串行运行场景;保留组件读到的最新 hook 结果和渲染/选择器计数供断言。
async function run(){
const f=await fixture(),engine=f.engine,container=document.createElement('div');container.style.display='none';document.body.append(container);const root=createRoot(container),reports:string[]=[],counts={title:0,element:0,mode:0,gesture:0,titleOnly:0};let titleOnly:unknown,titleSelections=0;let title:any,element:any,actions:any,mode:any,gesture:any,operation:any,status:any,assetA:any,assetB:any;
// 全局选择器只提取 a 的标题,equal 比较结果对象,检查无关 Root 发布不引发重渲染。
const Title=memo(function Title(){title=useCanvas(snapshot=>({title:snapshot.document.elements.get('a')?.data.title}),equal);counts.title++;return null;});
// 完整元素 hook 固定到 a 的第一代身份,稍后用于验证删除恢复后状态变为 stale。
const Element=memo(function Element(){element=useElement({id:'a',incarnation:1});counts.element++;return null;});
// 计数元素选择器实际执行次数,区分“跳过无关目标”与“目标变化但结果相等不渲染”。
const selectTitle=(view:ElementView)=>{titleSelections++;return view.status==='present'?view.element.data.title:view.status;};
// 只订阅目标标题字段;同元素其他字段变化允许重新选择,但不能增加组件渲染次数。
const ElementTitle=memo(function ElementTitle(){titleOnly=useElement({id:'a',incarnation:1},selectTitle);counts.titleOnly++;return null;});
// 捕获绑定到目标身份的命令 facade,检查稳定引用、历史选项和过期身份拒绝。
const Actions=memo(function Actions(){actions=useActions({id:'a',incarnation:1});return null;});
// 观察 v 视图的 mode,另一个 view 的 mode 更新不能使它重渲染。
const Mode=memo(function Mode(){mode=useMode({id:'a',incarnation:1});counts.mode++;return null;});
// 捕获目标绑定手势控制器;beginOnLayout 在替换目标的 layout effect 中立即开启新手势,以暴露旧 cleanup 误取消问题。
const Gesture=memo(function Gesture({targetId='a',beginOnLayout=false}:{targetId?:string;beginOnLayout?:boolean}){const binding=useGestureBindings({id:targetId,incarnation:1});gesture=binding;counts.gesture++;useLayoutEffect(()=>{if(beginOnLayout){assert(binding.begin().enabled,'replacement gesture did not begin');assert(binding.update({x:88}).enabled,'replacement gesture did not preview');}},[targetId,beginOnLayout]);return null;});
// 按逻辑操作 ID 观察操作及其请求;最初 missing 应没有记录,rename 后改绑真实 ID。
const Operation=memo(function Operation({id}:{id:string}){operation=useOperation(id);return null;});
// 独立观察同步、持久化错误和关闭状态,不靠业务文档再次变化触发观察。
const Status=memo(function Status(){status=useSyncStatus();return null;});
// 两个 slot 可同时借用同一资源,分别保存 hook 结果以检查共享 URL 及引用计数释放。
const Asset=memo(function Asset({id,slot}:{id:string;slot:'a'|'b'}){const value=useAsset(id);if(slot==='a')assetA=value;else assetB=value;return null;});
// 每次以 StrictMode 和 CanvasProvider 同步替换子树;owner 可替换成另一个 engine,随后等待 effects。
const render=async(children:any,owner=engine)=>{flushSync(()=>root.render(<StrictMode><CanvasProvider engine={owner} viewId="v">{children}</CanvasProvider></StrictMode>));await tick();};
try{
// 先等 synced 再记录动态基线;StrictMode 可能重复渲染,因此断言增量而非写死初始次数。
await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id="missing"/><Status/></>);await until(()=>status?.status==='synced');const baseline={...counts},originalActions=actions,initialTitleSelections=titleSelections;
// 改无关 b:窄订阅计数和 action 引用不变,元素标题选择器也不执行。
engine.editor.dispatch({type:'element.data',target:{id:'b',incarnation:1},patch:{title:'unrelated'}});await engine.settle();await tick();assert(equal(baseline,counts),'unrelated element/ACK rerendered narrow hooks');assert(actions===originalActions,'action facade identity drifted');assert(titleSelections===initialTitleSelections,'unrelated edit/ACK evaluated the element selector');
// 改 a 的 text 会执行其标题选择器,但标题没变时不得重渲染 ElementTitle。
const titleRenders=counts.titleOnly;engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'other field'}});await engine.settle();await tick();assert(titleSelections>initialTitleSelections&&counts.titleOnly===titleRenders&&titleOnly==='a','field selector did not isolate title from another field on the same target');reports.push('element ID routing skips unrelated selector evaluation; field selectors observe their target without rerendering for other fields');
// 经 actions.rename 验证命令历史 label/groupId、canonical 观察及 operation/request 状态。
const renamed=actions.rename('renamed',{label:'Hook rename',groupId:'hook-rename'});await engine.settle();const renamedHistory=engine.getSnapshot().history.entries.get(engine.getSnapshot().history.undo.at(-1)!);assert(renamedHistory?.label==='Hook rename'&&renamedHistory.groupId==='hook-rename','action facade dropped command history options');await until(()=>title.title==='renamed'&&titleOnly==='renamed');assert(element.status==='present'&&element.element.data.title==='renamed','element did not observe canonical rename');await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);assert(operation?.operation.phase==='resolved'&&operation.request?.phase==='resolved','operation hook did not include its request');reports.push('selectors suppress unrelated element and ACK renders; bound actions share validated history/ledger and operation/request observation');
// 手势预览只改变交互状态,不改业务几何、revision 或 history;取消不留撤销记录,mode 仍按 view 隔离。
const history=engine.getSnapshot().history.undo.length,revision=engine.getSnapshot().revision;assert(gesture.begin().enabled&&gesture.update({x:42}).enabled,'gesture did not acquire/update');await until(()=>gesture.active&&gesture.geometry.x===42);assert(engine.getSnapshot().document.elements.get('a')!.structure==='node'&&(engine.getSnapshot().document.elements.get('a') as any).geometry.x===0,'preview mutated committed geometry');assert(engine.getSnapshot().revision===revision,'preview created an edit');gesture.cancel();await until(()=>!gesture.active);assert(engine.getSnapshot().history.undo.length===history,'canceled preview changed history');assert(mode.setMode('editing').enabled,'view mode rejected');await until(()=>mode.mode==='editing');const modeRenders=counts.mode;engine.sessions.mode('w','a','resize');await tick();assert(counts.mode===modeRenders,'another view changed this view mode');
// 第二次手势提交只新增一个历史单元,租约释放后旧 commit 不再可写。
assert(gesture.begin().enabled,'second gesture did not acquire');gesture.update({x:55});assert(gesture.commit().status==='applied','gesture did not commit');await engine.settle();assert(engine.getSnapshot().history.undo.length===history+1,'gesture did not create exactly one history entry');assert(gesture.commit().status==='rejected','released gesture lease remained writable');reports.push('gesture preview/cancel/commit owns a token and one history unit; modes stay scoped to their view');
// 切换 hook 目标后立即在 layout effect 开新租约;旧控制器的 cancel/commit 和 cleanup 都不能碰新目标。
const previousGesture=gesture,beforeOwnerSwap=engine.getSnapshot();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture targetId="b" beginOnLayout/><Operation id={renamed.operationIds[0]}/><Status/></>);const replacementToken=engine.sessions.getSnapshot().gesture?.token;assert(!!replacementToken&&engine.sessions.getSnapshot().gesture?.targets[0].id==='b','old target cleanup canceled the new layout gesture');assert(previousGesture.commit().status==='rejected','old controls committed the replacement gesture');previousGesture.cancel();assert(engine.sessions.getSnapshot().gesture?.token===replacementToken,'old controls canceled the replacement gesture');assert(engine.getSnapshot()===beforeOwnerSwap,'owner switch or obsolete callback published document/history work');gesture.cancel();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);reports.push('gesture controller replacement owns an independent lease; old cleanup, cancel and commit cannot touch a new target gesture');
// 先检查只有 durability 完成也能清除 dirty,再注入 quota 验证 blocked/dirty,重试成功才恢复 synced。
engine.editor.updateRuntime(previous=>({...previous}));await engine.persist();await tick();await until(()=>status.status==='synced'&&!status.dirty);assert(!status.dirty,'durable-only metadata completion was not observable');f.fail(true);actions.rename('unsaved');await until(()=>status.status==='blocked'&&status.error==='quota');assert(status.dirty,'failed persistence appeared durable');f.fail(false);await engine.retry();await engine.settle();await until(()=>status.status==='synced'&&!status.dirty);reports.push('sync status observes durable-only completion and quota failure/retry without claiming in-memory edits are synced');
// 暂存任意图片类型字节只用于资源引用测试;不是 PNG 解码验证。
let resource='';engine.stageFiles([{name:'image.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([ref],tx)=>{resource=ref.id;tx.dispatch({type:'element.create',element:{...f.node('image'),componentId:'image',data:{title:'image',text:'',assetId:ref.id,src:ref.src}}});});await engine.settle();const reads=f.reads;
// 两个 hooks 必须共用一次读取和一个 URL;移除一个不影响另一个,最后一个卸载后 URL 才失效。
await render(<><Asset id={resource} slot="a"/><Asset id={resource} slot="b"/><Status/></>);await until(()=>assetA?.status==='ready'&&assetB?.status==='ready');assert(assetA.url===assetB.url&&f.reads===reads+1,'two resource hooks did not share one display read/URL');const url=assetA.url;assert(await(await fetch(url)).text()==='image bytes','asset hook did not expose original bytes');await render(<><Asset id={resource} slot="a"/><Status/></>);assert(await(await fetch(url)).text()==='image bytes','unmounting one reader revoked the other');await render(<Status/>);let revoked=false;try{await fetch(url);}catch{revoked=true;}assert(revoked,'last resource reader did not release its URL');reports.push('resource hooks share a URL, retain remaining readers and release the last handle on unmount');
// 读取尚未返回时卸载 hook,随后才放行;迟到资源结果应释放显示 pin,不能泄漏借用。
let release!:()=>void;f.gate(new Promise<void>(resolve=>release=resolve));const before=f.reads;await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>f.reads>before);await render(<Status/>);release();f.gate();await tick();assert(!engine.getResourcePins().pins.get(resource)?.has('display'),'late resource load leaked a display pin');
// 删除并撤销恢复元素后 incarnation 已变化;旧 facade 必须拒绝写入,不能自动改绑同 ID 的新对象。
await render(<><Element/><Actions/><Gesture/><Status/></>);const staleActions=actions;engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();engine.editor.history.undo();await engine.settle();await until(()=>element.status==='stale');const snapshot=engine.getSnapshot();assert(staleActions.rename('stale').status==='rejected'&&engine.getSnapshot()===snapshot,'old incarnation facade edited a restored element');reports.push('late resource loads release their pins and stale action targets cannot retarget restored incarnations');
// 关闭 engine 时 mounted hooks 也要观察到 closed/error 并释放资源。
await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>assetA.status==='ready');await engine.close();await until(()=>status.status==='closed'&&assetA.status==='error');reports.push('engine close is observable and releases mounted resource handles without owning a second project');
// 换 Provider 所属引擎后,资源字节和命令目标都必须来自新 owner,不能修改旧引擎快照。
const other=await fixture();try{
let otherResource='';other.engine.stageFiles([{name:'other.png',bytes:new Blob(['other identity bytes'],{type:'image/png'})}],([ref],tx)=>{otherResource=ref.id;tx.dispatch({type:'element.create',element:{...other.node('other-image'),componentId:'image',data:{title:'other',text:'',assetId:ref.id,src:ref.src}}});});await other.engine.settle();
const oldSnapshot=engine.getSnapshot();await render(<><Asset id={otherResource} slot="a"/><Actions/><Status/></>,other.engine);await until(()=>assetA.status==='ready');assert(await(await fetch(assetA.url)).text()==='other identity bytes','Provider change reused another engine resource handle');assert(actions.rename('second owner').status==='applied','new owner action did not dispatch');await other.engine.settle();assert(other.engine.getSnapshot().document.elements.get('a')!.data.title==='second owner'&&engine.getSnapshot()===oldSnapshot,'Provider change routed commands to the old owner');reports.push('Provider owner changes replace resource handles and action targets without writing to the previous engine');
}finally{await other.engine.close();}
// 部分来源完成前 b 和未知 ID 都是 unloaded;loading.complete 后才能分别判断 present 与 absent。
const partial=await fixture(true);try{
let b:any,missing:any;function Lookup(){b=useElement('b');missing=useElement('not-in-source');return null;}
await render(<><Lookup/><Status/></>,partial.engine);assert(b.status==='unloaded'&&missing.status==='unloaded','partial lookup guessed absence');const completed=await partial.engine.loading.complete(partial.full,new Map([['canvas',1]]),{sourceToken:'hook-partial'});assert(completed.status==='applied','hook partial fixture did not complete');await partial.engine.settle();await until(()=>b.status==='present'&&missing.status==='absent');reports.push('element hooks preserve unloaded versus absent until source completion and then expose the newly loaded element');
}finally{await partial.engine.close();}
// 只有所有场景通过才返回报告;finally 卸载 root、移除隐藏容器、恢复故障开关并清理 fixture。
return reports;
}finally{flushSync(()=>root.unmount());container.remove();f.fail(false);f.detach();await engine.close();}
}
// effect 仅注册页面测试入口;清理时先比较函数身份,防止旧挂载删除新实例注册的入口。
export function HookChecks(){useEffect(()=>{const host=globalThis as typeof globalThis&{__runCanvasHookChecks?:()=>ReturnType<typeof run>};host.__runCanvasHookChecks=run;return()=>{if(host.__runCanvasHookChecks===run)delete host.__runCanvasHookChecks;};},[]);return null;}
+14
View File
@@ -1,23 +1,35 @@
/**
* ACK 基线上的本地待处理操作重放测试。已被 ACK 包含的前缀应跳过,其余修改按顺序应用到私有草稿。
* 字段存在性、canvas 归属和 incarnation 都参与检查;后段冲突不能污染原 ACK 或操作账本。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,applyChanges,replayChanges} from '../../src/lib/canvas-core/index.ts';
import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts';
import {replayPending} from '../../src/lib/canvas-runtime/reconcile.ts';
// 使用内置类型校验重放结果,避免以空 registry 绕过元素约束。
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 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});
// 把各组修改登记为有序 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();}
// 分别从原始 ACK 和已确认第一步的 ACK 重放同一序列,最终标题都到 third,且输入 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')]]);
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',()=>{
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 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',()=>{
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'};
@@ -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 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',()=>{
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',()=>{
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);
+27
View File
@@ -1,38 +1,52 @@
/**
* 恢复值编码与同步/协作式封存测试:JSON 语义、不可变缓存、所有权、深度限制和精确 UTF-8 计数必须同时成立。
* 未知 getter、toJSON、Proxy 和可变内建对象可能执行调用者逻辑,不能仅凭 Object.freeze 就跳过捕获。
* 协作式路径对安全内部数据按预算让出,对未知回调维持同步捕获顺序;全局探针均需恢复。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {freeze,validJson} from '../../src/lib/canvas-core/values.ts';
import {encodeRecoveryValue,sealRecoveryImage,isSealedRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
// 用原生 stringify/parse 作为 JSON 规范化参照,包括省略字段、非有限数和自定义 toJSON。
const json=value=>JSON.parse(JSON.stringify(value));
// 以原生 JSON 为参照检查省略、稀疏/不可编码数组项、日期、装箱值、自定义 toJSON 和 own __proto__,输出须合法且冻结。
test('recovery encoding preserves JSON value semantics including omitted, sparse, special-key and custom values',()=>{
const value={number:-0,infinite:Infinity,missing:undefined,symbol:Symbol('s'),fn(){},array:[,undefined,()=>{},Symbol('s'),NaN],date:new Date('2026-01-01T00:00:00Z'),boxed:new Number(4),custom:{toJSON(key){return{key,text:'custom'};}},['__proto__']:{safe:true}};
const encoded=encodeRecoveryValue(value);assert.deepEqual(encoded,json(value));assert.equal(validJson(encoded),true);assert.equal(Object.getPrototypeOf(encoded),Object.prototype);assert.equal(Object.hasOwn(encoded,'__proto__'),true);assert.ok(Object.isFrozen(encoded.array));
});
// 安全深冻结数据可复用已编码副本;浅冻结外壳包着可变子项时必须重读,编码结果也不能反向修改输入。
test('only frozen data subtrees are reused and the encoded image is detached and immutable',()=>{
const shared=freeze({items:[{title:'stable',unknown:{retained:true}}]}),left=encodeRecoveryValue({shared}),right=encodeRecoveryValue({shared});assert.equal(left.shared,right.shared);assert.notEqual(left.shared,shared);assert.throws(()=>{left.shared.items[0].title='changed';},TypeError);
const mutable={title:'before'},shallow=Object.freeze({mutable});assert.equal(encodeRecoveryValue(shallow).mutable.title,'before');mutable.title='after';assert.equal(encodeRecoveryValue(shallow).mutable.title,'after');assert.equal(encodeRecoveryValue(shallow).mutable===mutable,false);
});
// 冻结 getter、动态 toJSON、Date 内部状态和继承的数组项仍可能变化;每次编码都应观察当时值。
test('frozen getters, custom prototypes and internally mutable objects are evaluated again',()=>{
let current='first';const getter=Object.freeze({get title(){return current;}});assert.equal(encodeRecoveryValue(getter).title,'first');current='second';assert.equal(encodeRecoveryValue(getter).title,'second');
let enabled=false;const dynamic={value:'ordinary'};Object.defineProperty(dynamic,'toJSON',{get(){return enabled?()=>({changed:true}):undefined;}});Object.freeze(dynamic);assert.deepEqual(encodeRecoveryValue(dynamic),{value:'ordinary'});enabled=true;assert.deepEqual(encodeRecoveryValue(dynamic),{changed:true});
const date=Object.freeze(new Date('2026-01-01Z'));const parent=Object.freeze({date});assert.deepEqual(encodeRecoveryValue(parent),json(parent));date.setUTCFullYear(2027);assert.deepEqual(encodeRecoveryValue(parent),json(parent));
const prototype=Object.create(Array.prototype);prototype[0]='old';const array=new Array(1);Object.setPrototypeOf(array,prototype);Object.freeze(array);assert.deepEqual(encodeRecoveryValue(array),['old']);prototype[0]='new';assert.deepEqual(encodeRecoveryValue(array),['new']);
});
// 缓存命中不能绕过深度上限;对象 undefined 字段省略,数组 undefined 项成为 null,循环和 BigInt 都不能正常编码。
test('cached subtree height cannot bypass the depth bound and omitted object fields do not count',()=>{
// 构造冻结包装层检查总深度;预热 leaf 缓存后仍须把缓存子树高度计入外层限制。
const nest=(count,leaf)=>{for(let i=0;i<count;i++)leaf=Object.freeze({child:leaf});return leaf;},leaf=freeze({value:1});encodeRecoveryValue(leaf);assert.equal(validJson(encodeRecoveryValue(nest(63,leaf))),true);assert.throws(()=>encodeRecoveryValue(nest(64,leaf)),/invalid_recovery_payload/);
assert.equal(validJson(encodeRecoveryValue(nest(64,Object.freeze({optional:undefined})))),true);assert.throws(()=>encodeRecoveryValue(nest(64,Object.freeze([undefined]))),/invalid_recovery_payload/);
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
});
// 只有工厂封存的对象被识别为 sealed;克隆和浅冻结仿造对象没有所有权,非法修订及格式版本被拒绝。
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(9,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(10,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(11,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(12,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(13,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(14,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(15,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(16,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(17,1,1,{})),true);assert.throws(()=>sealRecoveryImage(18,1,1,{}),/corrupt_recovery_image/);
});
// 对共享子树、Unicode/孤立代理字符和各类规范化输入核对 UTF-8 字节数;克隆 image 不继承内部计数缓存。
test('sealed image byte sizes match UTF-8 JSON for reused data and all codec normalization paths',async()=>{
const {sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'),shared=freeze({text:'中文😀\ud800\n"\\',items:[null,1,true]}),payloads=[null,'😀',-0,{shared,omitted:undefined,array:[,undefined,NaN]},new Date('2026-01-01Z'),{['__proto__']:'保留'},Object.freeze({get text(){return '动态😀';}})];
for(const payload of payloads)for(let revision=0;revision<3;revision++){const image=sealRecoveryImage(17,revision,revision,payload);assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength);assert.equal(sealedRecoveryByteLength(structuredClone(image)),undefined);}
});
// 未知 getter 与 toJSON 在首次让出前捕获,之后外部及 yield 回调改值不影响结果;粗时钟的大安全数组仍会主动让出。
test('cooperative sealing captures unknown values before yielding and retains exact bytes',async()=>{
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
@@ -56,6 +70,7 @@ test('cooperative sealing captures unknown values before yielding and retains ex
assert.ok(yields>beforeCoarse);assert.deepEqual(more.payload,large);
});
// 同一未知值的每次出现都独立求值;让出失败、过深数据及非法修订拒绝,不产生可用的半成品 image。
test('cooperative preparation evaluates every unsafe occurrence and rejects errors without an image',async()=>{
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
@@ -65,6 +80,7 @@ test('cooperative preparation evaluates every unsafe occurrence and rejects erro
assert.deepEqual(image.payload,{left:{dynamic:{title:1}},right:{dynamic:{title:2}}});
let ticks=0;
await assert.rejects(sealRecoveryImageAsync(17,1,1,dataArray(Array.from({length:1000},(_,i)=>i)),{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{throw Error('yield_failed');}}),/yield_failed/);
// 异步深度检查使用内部 dataRecord 包装;省略的对象字段和变成 null 的数组项占用深度不同。
const nested=(n,leaf)=>{for(let i=0;i<n;i++)leaf=dataRecord({child:leaf});return leaf;};
const leaf=freeze({value:1});encodeRecoveryValue(leaf);
await assert.rejects(sealRecoveryImageAsync(17,1,1,nested(64,leaf)),/invalid_recovery_payload/);
@@ -73,6 +89,7 @@ test('cooperative preparation evaluates every unsafe occurrence and rejects erro
await assert.rejects(sealRecoveryImageAsync(17,0,1,{}),/corrupt_recovery_image/);
});
// 临时拦截字符串 stringify 计数,证明标量规范化也分布在预算切片内,并在 finally 恢复全局函数。
test('primitive normalization and UTF-8 sizing occur inside the cooperative slice',async()=>{
const {dataArray}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
@@ -87,6 +104,7 @@ test('primitive normalization and UTF-8 sizing occur inside the cooperative slic
}finally{JSON.stringify=stringify;}
});
// 记录未知 Proxy 的所有 trap,要求异步封存与同步封存的调用次序、次数和输出相同。
test('unknown proxies retain the synchronous codec trap order and callback count',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
@@ -98,6 +116,7 @@ test('unknown proxies retain the synchronous codec trap order and callback count
});
// 同一异步 image 内重复安全容器可复用引用及计数;不安全后代在每次出现时仍独立捕获。
test('repeated owned containers reuse facts assembled earlier in the same asynchronous image',async()=>{
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
@@ -113,10 +132,12 @@ test('repeated owned containers reuse facts assembled earlier in the same asynch
assert.deepEqual(captured.payload,[{getValue:{value:1}},{getValue:{value:2}}]);
});
// 生成首次进入 codec 的大元素数据,让测试能区分第一次规范化与已预热缓存的复用。
const ownedRecord=async()=>{
const {detachElementData}=await import('../../src/lib/canvas-core/element-data.ts');
return detachElementData({id:'large',incarnation:1,structure:'node',ownerCanvasId:'canvas',componentId:'text',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'',rows:Array.from({length:1000},(_,i)=>({index:i,text:'owned-中文😀-'+i}))}});
};
// 首次处理真实内部拥有的大元素时,首次让出前只编码有限标量;总计仍处理全部字符串并精确计数。
test('first-time detached element JSON is normalized inside slices rather than captured in one walk',async()=>{
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const element=await ownedRecord(),stringify=JSON.stringify;let strings=0,first,tick=0;
@@ -128,6 +149,7 @@ test('first-time detached element JSON is normalized inside slices rather than c
}finally{JSON.stringify=stringify;}
});
// 准入前原型链已有 toJSON getter 时,即使数据由内部拥有也走同步捕获,调用次数与同步路径相同且不让出。
test('inherited toJSON accessors at admission keep detached elements on the synchronous capture path',async()=>{
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const left=await ownedRecord(),right=await ownedRecord(),previous=Object.getOwnPropertyDescriptor(Object.prototype,'toJSON');let calls=0,yields=0,tick=0;
@@ -139,11 +161,14 @@ test('inherited toJSON accessors at admission keep detached elements on the sync
}finally{if(previous)Object.defineProperty(Object.prototype,'toJSON',previous);else delete Object.prototype.toJSON;}
});
// 未知回调在捕获中改变数组 toJSON 时,已捕获 left 保持原值,后续 right 使用新语义,整体与同步 codec 相同。
test('unknown callbacks invalidate the detached JSON environment without changing earlier captures',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const previous=Object.getOwnPropertyDescriptor(Array.prototype,'toJSON');
// 恢复 Array.prototype.toJSON 的原描述符;测试内与 finally 都调用,防止环境探针影响其他用例。
const cleanup=()=>{if(previous)Object.defineProperty(Array.prototype,'toJSON',previous);else delete Array.prototype.toJSON;};
// left 先捕获,bridge getter 改写数组 toJSON,right 后捕获;同一 image 内顺序变化必须与同步 codec 一致。
const make=async()=>dataRecord({left:await ownedRecord(),bridge:{get value(){Object.defineProperty(Array.prototype,'toJSON',{configurable:true,value(){return ['changed-by-callback'];}});return 'bridge';}},right:await ownedRecord()});
try{
const expected=sealRecoveryImage(17,1,1,await make());cleanup();
@@ -154,9 +179,11 @@ test('unknown callbacks invalidate the detached JSON environment without changin
}finally{cleanup();}
});
// 完整元素藏在深层包装下时,无论未缓存还是已缓存,均须计入内部深度并拒绝越界。
test('deferred detached subtrees enforce the depth bound both cold and after cache reuse',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
// 用 61 层内部容器包裹完整元素,使其内部数据深度也触及限制;冷缓存与热缓存都要拒绝。
const nest=leaf=>{for(let i=0;i<61;i++)leaf=dataRecord({child:leaf});return leaf;};
const cold=await ownedRecord();await assert.rejects(sealRecoveryImageAsync(17,1,1,nest(cold)),/invalid_recovery_payload/);
const warm=await ownedRecord();await sealRecoveryImageAsync(17,1,1,warm);
+8
View File
@@ -1,16 +1,24 @@
/**
* 恢复 image 的协作式 JSON 输出测试:分段输出必须与原生 JSON.stringify 的内容及 UTF-8 字节数完全一致。
* 让出执行权只改变调度,不得改变捕获值或在 owner 丢失、宿主报错时返回半份成功结果。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {sealRecoveryImage,sealedRecoveryByteLength} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
import {serializeRecoveryImage} from '../../src/lib/canvas-runtime/recovery-json.ts';
// 每次读取时钟增加 10,迫使序列化跨预算让出;计数器用于证明实际调用了 yieldTask。
const options=()=>{let clock=0,yields=0;return{value:{now:()=>clock+=10,yieldTask:async()=>{yields++;}},get yields(){return yields;}};};
// 覆盖跨块 emoji、孤立代理字符、转义、整数键顺序、危险名称、非有限数和稀疏数组,对照原生字符串及字节数。
test('chunked recovery JSON preserves native byte order, Unicode boundaries, escaping and scalar normalization',async()=>{
const values=['a'.repeat(4095)+'👀'+'\n\t"\\'.repeat(4000),'a'.repeat(4095)+'\ud800x\udc00','\u2028\u2029中文'.repeat(5000)],properties=Object.fromEntries(Array.from({length:1800},(_,i)=>[String(1800-i),i%3?values[i%values.length].slice(0,20):null]));Object.defineProperty(properties,'__proto__',{value:{keep:true},enumerable:true});
const image=sealRecoveryImage(17,8,5,{values,properties,scalars:[NaN,Infinity,-0,null,true,false],sparse:Array(3)}),scheduling=options(),blob=await serializeRecoveryImage(image,scheduling.value);assert.equal(await blob.text(),JSON.stringify(image));assert.equal(blob.size,new TextEncoder().encode(JSON.stringify(image)).byteLength);assert.equal(blob.size,sealedRecoveryByteLength(image));assert.ok(scheduling.yields>0);assert.deepEqual(JSON.parse(await blob.text()),JSON.parse(JSON.stringify(image)));
});
// 封存后重复序列化不重新读调用者 getter;外部未拥有 image 则先防御性捕获,随后变更不能影响正在输出的值。
test('small owned subtree reuse does not reread caller getters or infer ownership from external freezing',async()=>{
let reads=0;const caller=Object.freeze({get text(){reads++;return'captured';}}),image=sealRecoveryImage(1,0,0,{rows:Array.from({length:1000},(_,i)=>({id:i,value:caller}))}),before=reads,a=await serializeRecoveryImage(image,options().value),b=await serializeRecoveryImage(image,options().value);assert.equal(reads,before);assert.equal(await a.text(),JSON.stringify(image));assert.equal(await b.text(),await a.text());
const external={formatVersion:1,rootRevision:0,localRevision:0,payload:{nested:'external'}};const pending=serializeRecoveryImage(external);external.payload.nested='late';assert.equal(JSON.parse(await(await pending).text()).payload.nested,'external');
});
// 粗时钟下大 image 仍须让出;check 检测 owner 丢失或 yieldTask 抛错时,以原错误拒绝而非返回部分 Blob。
test('coarse clocks yield for large images and a lost owner or host failure cannot return partial bytes',async()=>{
const image=sealRecoveryImage(1,1,1,{rows:Array.from({length:14000},(_,i)=>({id:i,text:'x'.repeat(100)}))});let yields=0;const blob=await serializeRecoveryImage(image,{now:()=>0,yieldTask:async()=>{yields++;}});assert.ok(yields>0);assert.equal(await blob.text(),JSON.stringify(image));
const failure=Error('owner lost');let gone=false,clock=0;await assert.rejects(serializeRecoveryImage(image,{now:()=>clock+=10,yieldTask:async()=>{gone=true;},check(){if(gone)throw failure;}}),error=>error===failure);clock=0;await assert.rejects(serializeRecoveryImage(image,{now:()=>clock+=10,yieldTask:async()=>{throw failure;}}),error=>error===failure);
+11
View File
@@ -1,22 +1,32 @@
/**
* 恢复编码缓存的所有权边界测试。真正由内部拥有的不可变文档/表可复用编码,外部浅冻结对象不能据此获得同样信任。
* 通过暂时拦截 Map 遍历检查重复工作,并比较旧 image 的内容和引用;拦截均在 finally 恢复。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,applyChanges} from '../../src/lib/canvas-core/document.ts';
import {createLedger} from '../../src/lib/canvas-runtime/ledger.ts';
import {encodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
// 节点工厂和双节点基线;未改动的 B 可用来检查局部编辑后编码是否复用。
const node=id=>({id,incarnation:1,ownerCanvasId:'root',componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}),doc=()=>createDocument('root',[{id:'root',parent:null}],[node('A'),node('B')]);
// 组装 encodeRecovery 需要的最小状态根,默认 acknowledged 与 working 共用文档,其他运行时表为空。
const snapshot=(document,rootRevision=0)=>({rootRevision,revision:0,document,operations:new Map(),history:{undo:[],redo:[],entries:new Map()},runtime:{acknowledged:document,versions:new Map(),runs:new Map(),resources:new Map(),resourcePins:new Map(),receipts:new Map(),conflicts:new Map(),ledger:createLedger().getSnapshot()}});
// 仅 rootRevision 变化时不再枚举文档;改动 A 后新 image 更新 A、复用 B,并保持先前 image 不变。
test('metadata-only roots reuse the document encoding and preserve prior images through a later edit',()=>{
const document=doc(),one=encodeRecovery(snapshot(document));let two;const values=Map.prototype.values;try{Map.prototype.values=function(){if(this.size===2)throw Error('metadata root enumerated document records');return values.call(this);};two=encodeRecovery(snapshot(document,1));}finally{Map.prototype.values=values;}assert.strictEqual(one.payload.document,two.payload.document);assert.strictEqual(one.payload.document,one.payload.runtime.acknowledged);const changed=applyChanges(document,[{kind:'field',target:{id:'A',incarnation:1},field:'data.text',before:'',after:'new'}]),three=encodeRecovery(snapshot(changed,2));assert.equal(three.payload.document.elements[0].data.text,'new');assert.equal(one.payload.document.elements[0].data.text,'');assert.strictEqual(three.payload.document.elements[1],one.payload.document.elements[1]);assert.strictEqual(three.payload.document,encodeRecovery(snapshot(changed,3)).payload.document);
});
// 外部 envelope 即使冻结,内部 Map 成员与元素数据仍可能变;每次编码重新捕获,既不冻结调用者也不污染旧 image。
test('unowned document envelopes reread mutable map membership and nested data without freezing callers',()=>{
const base=doc(),mutable=node('mutable'),elements=new Map([['mutable',mutable]]),document=Object.freeze({...base,elements}),one=encodeRecovery(snapshot(document));mutable.data.text='changed';elements.set('other',node('other'));const two=encodeRecovery(snapshot(document,1));assert.equal(two.payload.document.elements.length,2);assert.equal(two.payload.document.elements[0].data.text,'changed');assert.equal(one.payload.document.elements.length,1);assert.equal(one.payload.document.elements[0].data.text,'');assert.equal(Object.isFrozen(mutable),false);const lookalike=Object.freeze({...base});assert.notStrictEqual(encodeRecovery(snapshot(lookalike)).payload.document,encodeRecovery(snapshot(lookalike,1)).payload.document);
});
// applyChanges 接管表成员关系并不证明外部元素深不可变:源表后来加项不进入新表,嵌套数据变化仍须重新捕获。
test('factory table ownership does not imply deep immutability of values inherited from an external document',()=>{
const base=doc(),mutable=node('external'),source={...base,elements:new Map([['external',mutable]])},document=applyChanges(source,[]),one=encodeRecovery(snapshot(document));mutable.data.text='later';source.elements.set('new',node('new'));const two=encodeRecovery(snapshot(document,1));assert.equal(two.payload.document.elements.length,1);assert.equal(two.payload.document.elements[0].data.text,'later');assert.equal(one.payload.document.elements[0].data.text,'');assert.equal(Object.isFrozen(mutable),false);
});
import {readMap,freeze} from '../../src/lib/canvas-core/values.ts';
import * as publicCore from '../../src/lib/canvas-core/index.ts';
// 内部表编码可跨元数据代次复用,新增表项只生成新成员集合并复用安全旧值;内部 provenance 也不能泄漏为公共 API。
test('metadata roots reuse owned table entry encodings while new tables retain old image membership',()=>{
assert.equal(Object.hasOwn(publicCore,'isOwnedMapView'),false,'provenance is an internal codec optimization');
const state=snapshot(doc()),entries=readMap(new Map([['one',freeze({text:'first'})],['two',freeze({text:'second'})]]));state.operations=entries;state.history.entries=entries;state.runtime.receipts=entries;const one=encodeRecovery(state),iterate=Map.prototype[Symbol.iterator];let two;
@@ -24,6 +34,7 @@ test('metadata roots reuse owned table entry encodings while new tables retain o
assert.strictEqual(two.payload.operations,one.payload.operations);assert.strictEqual(two.payload.history.entries,one.payload.history.entries);assert.strictEqual(two.payload.runtime.receipts,one.payload.runtime.receipts);assert.strictEqual(one.payload.operations,one.payload.runtime.receipts);
const changed=readMap(new Map([...entries,['three',freeze({text:'third'})]])),three=encodeRecovery({...state,rootRevision:2,operations:changed});assert.equal(three.payload.operations.length,3);assert.equal(one.payload.operations.length,2);assert.strictEqual(three.payload.operations[0][1],one.payload.operations[0][1]);
});
// 冻结外部 Map 不冻结成员或阻止 set;内部表里的浅冻结值也可能含可变子项,两种情况都必须重读并保留旧快照。
test('external maps and immutable-looking maps reread membership; owned tables still reread mutable nested values',()=>{
const state=snapshot(doc()),external=Object.freeze(new Map([['one',{text:'first'}]]));state.runtime.receipts=external;const one=encodeRecovery(state);external.get('one').text='second';external.set('two',{text:'other'});const two=encodeRecovery({...state,rootRevision:1});assert.deepEqual(two.payload.runtime.receipts,[['one',{text:'second'}],['two',{text:'other'}]]);assert.equal(one.payload.runtime.receipts.length,1);assert.equal(one.payload.runtime.receipts[0][1].text,'first');assert.equal(Object.isFrozen(external.get('one')),false);
const mutable={text:'first'},shallow=Object.freeze({mutable}),owned=readMap(new Map([['one',shallow]]));state.runtime.receipts=owned;const three=encodeRecovery({...state,rootRevision:2});mutable.text='changed';const four=encodeRecovery({...state,rootRevision:3});assert.equal(three.payload.runtime.receipts[0][1].mutable.text,'first');assert.equal(four.payload.runtime.receipts[0][1].mutable.text,'changed');assert.equal(Object.isFrozen(mutable),false);
+15
View File
@@ -1,12 +1,20 @@
/**
* 渐进复制尚未完成时的真实浏览器原生输入探针,由 renderer 集成 runner 注入 CDP/求值/等待端口。
* 在原卡片上双击、输入中文组合及连续字符,跨早/中/尾发布阶段检查同一 textarea 的 DOM、焦点与呈现。
* evaluate 模板字符串是在页面执行的独立脚本,保持其原字节;这里只在调用边界解释内部回调与读数。
*/
/** Native input while a real progressive copy is still publishing. */
import assert from 'node:assert/strict';
// 导出给 runner:count 是原始节点规模;返回每次 input 的时间/内容/焦点样本,供外层报告或性能门槛使用。
export async function editDuringCopy({call,evaluate,wait,count}){
// 至少 1000 节点才启用并发探针,先等待首帧出现且复制还未完成,保证输入发生在真实渐进发布期间。
assert.ok(count>=1000,'concurrent input probe requires at least 1000 source nodes');
await wait('window.__canvasPerformance.read().firstPresentedFrameMs!==null','copy did not present before native input');
assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy finished before input probe');
// Copied cards overlap originals. Find an actually exposed point; never send a
// synthetic double-click through the copied card to the original underneath.
// 页面脚本先找原始 perf-0 文本标签,再按屏幕网格调用 elementFromPoint,寻找未被副本遮住的原生命中点。
const point=await evaluate(`(()=>{
const label=document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]');
if(!label)throw Error('original text label is missing');
@@ -16,9 +24,11 @@ export async function editDuringCopy({call,evaluate,wait,count}){
if(label.contains(document.elementFromPoint(x,y)))return{x,y};
throw Error('original text has no exposed native input point');
})()`);
// 通过 CDP 发送真实双击按下/释放,再等待原 textarea 进入可写焦点,不能用合成事件穿透副本。
await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
await wait(`document.activeElement===document.querySelector('.react-flow__node[data-id="perf-0"] textarea')&&!document.activeElement.readOnly`,'original text did not enter editing during copy');
// 安装 input 监听器:每次记录输入值、阶段及已提交数量,用两次 RAF 后的时间近似观察到后续呈现机会。
await evaluate(`(()=>{
const element=document.activeElement,samples=[];
const input=()=>{
@@ -36,6 +46,7 @@ export async function editDuringCopy({call,evaluate,wait,count}){
return Number(status.match(/·\\s*(\\d+)\\s*项/)?.[1]??0);
},dispose(){element.removeEventListener('input',input);}};
})()`);
// 先启动中文输入法组合再提交文本,随后分次插入 A/B/C;每次都等对应样本进入后续帧。
await call('Input.imeSetComposition',{text:'复',selectionStart:1,selectionEnd:1});
await call('Input.insertText',{text:'复制期间输入'});
for(const text of [' A',' B',' C']){
@@ -44,6 +55,7 @@ export async function editDuringCopy({call,evaluate,wait,count}){
}
// Keep the same native editor alive through later publication and ACK work.
// Small scenarios can finish during the early burst; the scale gate uses 10k.
// 万级场景还在 50%/90% 进度注入 M/T,保持同一编辑器不重开;测量 CDP round trip 与样本帧延迟是不同读数。
if(count>=10000)for(const [phase,fraction,text] of [['middle',.5,' M'],['tail',.9,' T']]){
await wait(`window.__copyInputProbe.progress()>=${Math.floor((count+3)*fraction)}`,'copy did not reach '+phase+' publication');
assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy completed before '+phase+' input');
@@ -53,15 +65,18 @@ export async function editDuringCopy({call,evaluate,wait,count}){
await wait(`window.__copyInputProbe.samples.at(-1)?.phase===${JSON.stringify(phase)}&&window.__copyInputProbe.samples.at(-1)?.frameAt!==undefined`,phase+' input did not reach a frame');
await evaluate(`window.__copyInputProbe.samples.at(-1).dispatchRoundTripMs=${dispatchRoundTripMs};true`);
}
// 最后页面脚本读取样本与焦点状态,移除监听器、blur 并删除探针全局变量;断言使用清理前捕获的结果。
const result=await evaluate(`(()=>{
const probe=window.__copyInputProbe,result={samples:probe.samples,value:probe.element.value,retained:probe.element.isConnected&&document.activeElement===probe.element};
probe.dispose();probe.element.blur();delete window.__copyInputProbe;return result;
})()`);
// 检查同一原生输入节点/焦点留存及完整文本后缀,复制过程不能重建编辑 DOM 或覆盖用户输入。
assert.equal(result.retained,true,'copy discarded native input DOM or focus');
assert.ok(result.value.endsWith(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'native composition/typing was overwritten');
assert.ok(result.samples.some(sample=>sample.copyActive),'all native input happened after copy completion');
if(count>=10000)for(const phase of ['middle','tail'])assert.ok(result.samples.some(sample=>sample.phase===phase&&sample.copyActive),phase+' input was not observed before completion');
assert.ok(result.samples.every(sample=>sample.retained),'copy interrupted an input presentation frame');
// 每个样本挂载节点数仍少于 100,证明此探针未因原生编辑挂载全部离屏节点;这不是通用 FPS 保证。
assert.ok(result.samples.every(sample=>sample.mountedNodes<100),'native editing mounted offscreen nodes during copy');
return result;
}
+9
View File
@@ -1,3 +1,7 @@
/**
* 浏览器渲染集成专用插件:模型定义与 ReactFlow renderer 一起通过 CanvasRuntimeExtension 注入。
* 页面全局计数和 editing facade 仅用于测试观察,数据编辑仍经过生产命令/事务入口。
*/
"use client";
import {useScopedNodeEditing} from '@/components/canvas/node-editing';
import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgeProps } from '@xyflow/react';
@@ -5,15 +9,20 @@ import { defineComponent, editable, movable } from '@/lib/canvas-core/index';
import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow';
import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow';
import { useCanvasBinding,useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow';
// 按元素 ID 计数真实 renderer 调用,供外层检查无关元素是否重渲染,不以 DOM 数量代替渲染次数。
function rendered(id:string){const host=globalThis as typeof globalThis&{__fixtureRenders?:Record<string,number>};const counts=host.__fixtureRenders??={};counts[id]=(counts[id]??0)+1;}
// 通过 useElement 取得当前原记录,捕获有 scoped target 与无绑定两种旧 editing facade,交给外层验证桥接隔离。
function Meter({data}:NodeProps<ProjectionNode>){
const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;rendered(record.id);const legacy=useScopedNodeEditing(record.id,data.__headlessTarget),unbound=useScopedNodeEditing(record.id),binding=useCanvasBinding();const host=globalThis as typeof globalThis&{__fixtureEngine?:typeof binding.engine;__fixtureLegacy?:Record<string,typeof legacy>;__fixtureUnbound?:Record<string,typeof legacy>};host.__fixtureEngine=binding.engine;(host.__fixtureLegacy??={})[record.id]=legacy;(host.__fixtureUnbound??={})[record.id]=unbound;
// Increase 按钮阻止点击冒泡,并在一个事务里修改 metric 与 raw,要求只产生一个历史单元;自定义 Handle ID 供连线端口测试。
return <div data-renderer-fixture="meter" style={{width:'100%',height:'100%',background:'white',border:'1px solid #ded7ce',padding:16}}>
<span>Metric {String(record.data.metric)}</span><button className="nodrag" aria-label={`Increase ${record.id}`} onClick={event=>{event.stopPropagation();commands.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{metric:Number(record.data.metric)+1}});tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{raw:{retained:true,edited:true}}});},{label:'Increase metric'});}}>Increase</button>
<Handle type="source" id="custom-out" position={Position.Right}/><Handle type="source" id="custom-in" position={Position.Left}/>
</div>;
}
// 自定义边记录渲染次数,使用 ReactFlow Bezier path 绘制;标题从原 connection data 读取 metric。
function MeterEdge(props:EdgeProps<ProjectionEdge>){rendered(props.id);const [path]=getBezierPath(props);return <g data-renderer-fixture="metric-edge"><title>Metric {String(props.data?.__headlessRecord?.data.metric)}</title><BaseEdge id={props.id} path={path}/></g>;}
// 两种模型分别校验 metric,节点安装编辑/移动能力;renderer 键精确匹配 componentId、dataVersion 与 structure。
export const fixtureExtension:CanvasRuntimeExtension={
components:[defineComponent({id:'fixture:meter',dataVersion:1,structure:'node',validate:data=>Number.isSafeInteger(data.metric),features:[editable(),movable()],copy:{references:[],omit:[]}}),defineComponent({id:'fixture:metric-edge',dataVersion:1,structure:'connection',validate:data=>typeof data.metric==='number',features:[],copy:{references:[],omit:[]}})],
renderers:[{componentId:'fixture:meter',dataVersion:1,structure:'node',rendererId:'fixture-meter',render:Meter},{componentId:'fixture:metric-edge',dataVersion:1,structure:'connection',rendererId:'fixture-metric-edge',render:MeterEdge}],
+35
View File
@@ -1,42 +1,61 @@
/**
* 旧画布迁移的浏览器集成 fixture 与 CDP 验证流程,由 renderer runner 提供页面、输入、轮询和假服务端。
* 五份独立旧画布分别验证脏写排空、其他视图共享锁、未知请求精确恢复、已有草稿及 pending 期间新增草稿。
* 这是可执行探针定义;Node 单元测试不会自动执行这些浏览器步骤,也不把控制台 PASS 文本当作已运行证据。
* evaluate/until 的页面脚本字符串保持原字节,在调用边界解释其中的查找、注入和状态检查。
*/
import assert from 'node:assert/strict';
import {writeFile} from 'node:fs/promises';
import {createDocument} from '../../src/lib/canvas-core/index.ts';
// 创建可安装到测试 HTTP server 的路由处理器;复用外部 boards/documents/versions/model,迁移成功后让主 runner 提供 canonical 路由。
export function createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}){
// 五个 fixture 各有旧节点、版本、迁移回执与故障开关;loss before/after 区分提交前失败与提交后丢响应。
const states=Array.from({length:5},(_,index)=>{
const root=id(7000+index*10),element=id(7001+index*10);
boards.push({id:root,workspace_id:workspace,kind:'board',version:1,title:'Legacy upgrade '+index});
return{root,element,version:1,ready:false,loss:undefined,holdPatch:false,patchWait:undefined,releasePatch:undefined,receipts:new Map(),keys:new Map(),patches:0,posts:[],document:{schema_version:2,nodes:[{id:'old-card',type:'plainText',position:{x:500,y:200},width:240,height:100,data:{kind:'plain-text',title:'Old card',text:'Legacy content'}}],edges:[],viewport:{x:0,y:0,zoom:1}}};
});
// handle 只处理匹配 fixture 根的旧 canvas/迁移路由,返回 true 表示已响应,其余交给外层服务。
return{states,async handle(request,path,reply,bytes){
const state=states.find(value=>path.includes('/materials/'+value.root+'/canvas'));if(!state)return false;
const base=`/materials/${state.root}/canvas`,raw=bytes.length?JSON.parse(bytes.toString()):undefined;
// 未迁移时 canonical model 返回带 source_versions 的明确 migration_required;此 header 才能开启升级入口。
if(path.endsWith(base+'/model')&&!state.ready){reply(409,{error:'canonical_migration_required',root_id:state.root,document_format_version:1,source_versions:{[state.root]:state.version}});return true;}
// 旧快照 GET 返回当前 document;PATCH 校验账户和 If-Match,并禁止 ready 后旧 writer 再写入。
if(path.endsWith(base)){
if(request.method==='PATCH'){
assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['if-match'],`"${state.version}"`);assert.equal(state.ready,false,'retired snapshot writer sent after migration');
state.document=raw.document;state.version++;state.patches++;
// 可把旧 PATCH 的响应停在 gate,让 UI 升级必须等待脏 writer 收到确认,而不是仅等本地文本更新。
if(state.holdPatch){state.patchWait=new Promise(resolve=>state.releasePatch=resolve);await state.patchWait;}
reply(200,{version:state.version});
}else reply(200,{version:state.version,document:state.document});return true;
}
// 提供旧画布的最小能力与空 run 列表,保持 UI 能正常打开,避免无关加载缺口干扰迁移。
if(path.endsWith(base+'/capabilities')){reply(200,{subgraphs:{member_positions:true}});return true;}
if(path.endsWith(base+'/ai/runs')&&request.method==='GET'){reply(200,{runs:[]});return true;}
// 按原 key 查询已保存回执,未命中回 unknown;恢复测试靠这条路径消除执行不确定性。
if(path.includes(base+'/model/operations/')&&request.method==='GET'){
const key=path.split('/').at(-1),receipt=state.receipts.get(key);reply(receipt?200:404,receipt??{status:'unknown',request_id:key});return true;
}
// 迁移 POST 校验账户/幂等头并记录原始字符串;明确失败、精确重试与成功转换分别处理。
if(path.endsWith(base+'/model/migrate')){
assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],raw.request_id);
state.posts.push({id:raw.request_id,body:bytes.toString()});
// 模拟尚未应用就返回 503,不能因此生成成功回执;恢复时必须再查 unknown 并精确重试。
if(state.loss==='before'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
// 已有请求校验完全相同字节;新请求版本冲突返回 terminal not-applied,否则只执行一次身份转换。
let receipt=state.receipts.get(raw.request_id);
if(receipt){assert.equal(state.keys.get(raw.request_id),bytes.toString());}
else if(raw.expected_versions[state.root]!==state.version){
receipt={protocol_version:2,request_id:raw.request_id,status:'not-applied',terminal:true,error:'migration_version_conflict'};
}else{
// 读取最后一次已确认的旧节点内容,分配 fixture 的新 canonical ID,增加版本并退休旧 writer。
const old=state.document.nodes[0];state.version++;state.ready=true;versions.set(state.root,state.version);
documents.set(state.root,createDocument(state.root,[{id:state.root,parent:null}],[{id:state.element,incarnation:1,ownerCanvasId:state.root,componentId:'plain-text',dataVersion:1,structure:'node',data:{title:old.data.title,text:old.data.text},geometry:{...old.position,width:old.width,height:old.height,rotation:0}}]));
receipt={protocol_version:2,request_id:raw.request_id,status:'applied',source_versions:raw.expected_versions,identity_map:[{sourceCanvasId:state.root,sourceId:old.id,structure:'node',elementId:state.element}],model:model(documents.get(state.root))};
}
// 先保存请求事实和字节,再处理 loss after;即使客户端收到 503,后端已经可查询成功。
state.receipts.set(raw.request_id,receipt);state.keys.set(raw.request_id,bytes.toString());
if(state.loss==='after'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
reply(receipt.status==='applied'?200:412,receipt);return true;
@@ -45,31 +64,43 @@ export function createRendererMigrationFixture({id,account,workspace,boards,docu
}};
}
// 执行实际页面输入/重载/多视图场景,参数均由外部 runner 注入;本函数不自己启动浏览器或应用。
export async function verifyRendererMigration({fixture,call,evaluate,until,port,workspace,account,pause}){
// 统一构造测试本机路由,后续在五个独立画布之间跳转。
const route=root=>`http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
// 返回页面求值表达式,在“画布升级”区域按精确文字找按钮;JSON.stringify 保证文字安全嵌入脚本。
const button=label=>`[...document.querySelectorAll('[aria-label="画布升级"] button')].find(button=>button.textContent.trim()===${JSON.stringify(label)})`;
// 先在页面取已启用按钮中心,再用 CDP 发送真实鼠标按下/释放;不是调用 DOM click 的合成事件。
async function click(label){const point=await evaluate(`(()=>{const button=${button(label)};if(!button||button.disabled)throw Error('missing enabled upgrade button');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});}
// 旧编辑器可用条件同时检查升级入口和画布非 inert,避免只凭按钮出现就认为旧写入已放开。
const editable=()=>`!!${button('升级画布')}&&!document.querySelector('.react-flow').parentElement.inert`;
// 账户/workspace 级持久化迁移键,稍后比较保存的冻结 body 与服务实际收到字节。
const storageKey=`endoleap_canvas_migration_v1:${account}:${workspace}`;
// 五份状态按下方场景依次使用,互不污染版本、回执和旧 PATCH 数量。
const[a,b,c,d,e]=fixture.states;
await until(editable(),'legacy editor did not claim an editing lease');
// 场景 A:先找原旧卡片可编辑位置并用 elementFromPoint 验证没被遮挡,再真实双击/输入形成脏稿。
const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="old-card"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.4,y:r.y+Math.min(25,r.height*.3)}})()`);
assert.equal(await evaluate(`document.elementFromPoint(${point.x},${point.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'old-card','legacy edit point is covered by another surface');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement?.tagName==='TEXTAREA'&&!document.activeElement.readOnly`,'legacy text did not enter editing');
await call('Input.insertText',{text:' migration edit'});const edited=await evaluate('document.activeElement.value');
// 暂停旧 PATCH 响应并让随后迁移丢 ACK,检查迁移没有越过旧写入确认屏障且已包含最新正文。
a.holdPatch=true;a.loss='after';await click('升级画布');
for(let i=0;i<100&&!a.releasePatch;i++)await pause(20);assert.ok(a.releasePatch,'upgrade did not drain its dirty legacy writer');assert.equal(a.posts.length,0,'migration crossed an unacknowledged legacy PATCH');assert.equal(a.document.nodes[0].data.text,edited);
a.holdPatch=false;a.releasePatch();
await until(`!!${button('继续查询升级')}`,'lost migration acknowledgement did not leave a resumable UI');
assert.equal(a.posts.length,1);assert.equal(await evaluate(`document.querySelector('.react-flow').parentElement.inert`),true);
// 未知期间旧画布应 inert;保存 body 必须与实际 POST 一致,并输出该 UI 状态截图供测试产物审查。
const frozen=await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}))`);assert.equal(frozen.body,a.posts[0].body);
await writeFile('/private/tmp/headless-migration-unknown.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
// 刷新页面后自动查询原迁移并进入 canonical,检查只迁移一次、旧 PATCH 只一次,已编辑正文保留。
await call('Page.reload');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${a.element}"]')&&!localStorage.getItem(${JSON.stringify(storageKey)})`,'canonical reload did not reconcile its saved migration');
assert.equal(a.posts.length,1);assert.equal(a.patches,1);assert.equal(a.version,3);assert.equal(JSON.parse(a.posts[0].body).expected_versions[a.root],2);
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${a.element}"] textarea').value`),edited);
console.log('PASS legacy dirty writer drains before identity migration; lost ACK reload queries once and retires snapshot writes');
// 场景 B:隐藏同源 iframe 再开同一旧画布,验证实际 Web Locks 共享租约阻止独占迁移;移除 peer 后才能成功。
await call('Page.navigate',{url:route(b.root)});await until(editable(),'second legacy editor did not open');
await evaluate(`(()=>{const frame=document.createElement('iframe');frame.id='migration-peer';frame.src=location.href;frame.style.display='none';document.body.append(frame);})()`);
await until(`(()=>{const doc=document.getElementById('migration-peer')?.contentDocument;return !!doc?.querySelector('[aria-label="画布升级"] button')&&!doc.querySelector('.react-flow')?.parentElement.inert;})()`,'another legacy view did not acquire its shared lease');
@@ -79,6 +110,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
await click('升级画布');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${b.element}"]')`,'upgrade did not open the canonical editor after the peer closed');assert.equal(b.posts.length,1);
console.log('PASS actual Web Locks exclude identity migration while another legacy view is editable');
// 场景 C:迁移提交前失败再发生远端版本变化,重载仍保持原请求 gate;精确重试零写入拒绝后才允许显式重新准备。
await call('Page.navigate',{url:route(c.root)});await until(editable(),'third legacy editor did not open');c.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'uncommitted request did not remain resumable');
const original=c.posts[0];c.version++;c.document.nodes[0].data.text='Remote edit before recovery';
await call('Page.reload');await until(`!!${button('继续查询升级')}&&document.querySelector('.react-flow').parentElement.inert`,'reload enabled old writes before resolving the frozen migration');await pause(1000);assert.equal(c.patches,0);
@@ -90,6 +122,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${c.element}"] textarea').value`),'Remote edit before recovery');
console.log('PASS legacy reload remains frozen until exact retry resolves; known rejection requires explicit fresh preparation');
// 场景 D:已有不同本地恢复草稿,升级必须先展示并等待显式草稿选择,不能在恢复选择前 PATCH 或迁移。
await call('Page.navigate',{url:route(d.root)});await until(editable(),'draft conflict fixture did not open');
const draft=structuredClone(d.document);draft.nodes[0].data.text='Draft from another closed editor';
await evaluate(`localStorage.setItem(${JSON.stringify(`endoleap_canvas_v1:${workspace}:${d.root}:recovery`)},${JSON.stringify(JSON.stringify({document:draft,baseVersion:1,dirty:true}))})`);
@@ -103,6 +136,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${d.element}"] textarea').value`),draft.nodes[0].data.text);
console.log('PASS a different cached legacy draft blocks migration until explicit recovery and survives the handoff');
// 场景 E:迁移结果未知期间出现恢复草稿,先保留草稿但不弹 modal 阻塞查询;明确拒绝后才允许恢复草稿和新准备。
await call('Page.navigate',{url:route(e.root)});await until(editable(),'pending draft fixture did not open');
e.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'pending draft fixture did not retain the original upgrade');
const retained=structuredClone(e.document);retained.nodes[0].data.text='Retained while migration is unknown';
@@ -114,6 +148,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
assert.equal(await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(recoveryKey)})).document.nodes[0].data.text`),retained.nodes[0].data.text);assert.equal(e.patches,0);
await click('继续查询升级');await until(`!!document.querySelector('dialog[open]')&&!document.querySelector('.react-flow').parentElement.inert`,'terminal rejection did not make the retained draft recoverable');
assert.equal(e.posts.length,2);assert.deepEqual(e.posts[1],e.posts[0]);
// 用户明确恢复保留稿后,再点击重新准备产生新迁移 key;新 canonical 编辑器应显示该草稿正文。
const retainedPoint=await evaluate(`(()=>{const button=[...document.querySelectorAll('dialog button')].find(button=>button.textContent==='恢复本地草稿');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...retainedPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...retainedPoint,button:'left',buttons:0,clickCount:1});
await until(`!document.querySelector('dialog[open]')`,'retained draft choice did not close recovery');
+40 -3
View File
@@ -1,32 +1,46 @@
/** Actual GraphPage/React Flow + IndexedDB probe; fixture HTTP server, not production service. */
/**
* 实际 GraphPage/ReactFlow 页面与 IndexedDB 的输入、保留和深复制探针,HTTP 后端由 fixture 提供。
* 调用者注入 CDP、页面 evaluate、服务文档及报告路径;本文件不独立启动浏览器或构建项目。
* 将场景断言与耗时/堆采样分别报告:计时是本次环境的观测,不构成通用 SLA;内嵌页面脚本保持测试输入原字节。
*/
import assert from 'node:assert/strict';
import {editDuringCopy} from './renderer-copy-input.mjs';
import {writeFile} from 'node:fs/promises';
import {setTimeout as pause} from 'node:timers/promises';
import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts';
// 依次验证原生编辑、离屏输入保留、宿主面板、portal 和大分区复制,再写报告与成功截图。
export async function measureRenderer({call,evaluate,documents,calls=[],root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',bundler='webpack',buildMs}){
// 保存测量墙上起点、堆样本及复制状态;target 用于离屏裁剪断言,不是最终服务端元素总数。
const target=2*count+3,started=Date.now(),heapSamples=[];let copying=false,nextHeap=0;
// 每次条件轮询有四分钟墙上截止并检查页面异常;复制中可每十秒取堆与回执保留快照,写 .heap.json。单次 await 仍依赖注入调用器返回。
const wait=async(expression,label)=>{const deadline=Date.now()+240000;while(Date.now()<deadline){if(exceptions.length)throw Error(exceptions.join('\n'));if(copying&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'&&Date.now()>=nextHeap){const heap=await call('Runtime.getHeapUsage'),retention=await evaluate('window.__fixtureReceiptRetention');heapSamples.push({wallMs:Date.now()-started,serverElements:documents.get(root)?.elements.size,heap,retention});nextHeap=Date.now()+10000;await writeFile(reportPath+'.heap.json',JSON.stringify({baseline,heapSamples},null,2));}if(await evaluate(expression))return;await pause(100);}throw Error(label);};
let report,duringCopyInput;
// 环境开关决定是否在复制期间额外输入;默认仅测复制,报告 scenario 据此区分。
const concurrentInput=process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1';
try{
// 等真实 frame 与至少两个节点挂载;未选择时不应保留空节点工具栏订阅。
await wait(`!!document.querySelector('.react-flow__node[data-id="perf-frame"]')&&document.querySelectorAll('.react-flow__node').length>=2`,'initial large canvas did not render');
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),0,'unselected nodes retained empty toolbar subscriptions');
// 读取页面 performance.now,是相对页面时间原点的就绪观测,不是函数 Date.now 起点的独立渲染耗时。
const initialReadyMs=await evaluate('performance.now()');console.log('PERF initial scene ready '+Math.round(initialReadyMs)+' ms');
// Use native input: synthetic focus alone misses the editing-mode transition.
// 使用原生输入进入编辑;仅脚本 focus 不会覆盖真实编辑模式切换。
await wait(`!!document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]')`,'first visible text card missing');
// 记录双击前后 DOM 数量,用于断言进入单卡编辑不会把离屏全集挂载。
const beforeEditing=await evaluate('document.querySelectorAll(".react-flow__node").length');
const editPoint=await evaluate(`(()=>{const rect=document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:rect.x+60,y:rect.y+30};})()`);
// 经 CDP 发送原生双击,随后验证 activeElement 为可写 textarea,避免只用脚本 focus 绕过编辑模式。
await call('Input.dispatchMouseEvent',{type:'mousePressed',...editPoint,button:'left',buttons:1,clickCount:2});
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...editPoint,button:'left',buttons:0,clickCount:2});
await wait(`document.activeElement===document.querySelector('.react-flow__node[data-id="perf-0"] textarea')&&!document.activeElement.readOnly`,'native text editing did not start');
await evaluate('window.__performanceEditor=document.activeElement;new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
const duringEditing=await evaluate('document.querySelectorAll(".react-flow__node").length');
if(count>=100)assert.ok(duringEditing<=beforeEditing+10,'editing one visible card mounted the offscreen canvas');
// 输入后同一 DOM、焦点和文本都要保留,再开启 IME 并将目标平移到屏幕外。
await call('Input.insertText',{text:'Native editing remains responsive'});
assert.equal(await evaluate('window.__performanceEditor.isConnected&&document.activeElement===window.__performanceEditor&&window.__performanceEditor.value.includes("Native editing remains responsive")'),true,'native input lost its DOM, focus or value');
await evaluate('window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector(".react-flow__viewport")).transform);true');
// 先建立组合输入再平移;目标确实离屏且仍保留输入 DOM,正常规模下不能因保留一个 editor 挂载全集。
await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1});
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
@@ -34,6 +48,7 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
assert.equal(await evaluate('(()=>{const r=window.__performanceEditor.getBoundingClientRect();return r.right<0||r.bottom<0||r.left>innerWidth||r.top>innerHeight;})()'),true,'IME probe did not move its owner offscreen');
const offscreenEditing=await evaluate('document.querySelectorAll(".react-flow__node").length');
if(count>=100)assert.ok(offscreenEditing<=10,'retaining one offscreen editor mounted the complete canvas');
// 离屏提交组合文本后恢复 viewport 并 blur,等同步完成再开始复制计时,排除上一轮未保存输入干扰。
await call('Input.insertText',{text:'保留输入'});
assert.equal(await evaluate('window.__performanceEditor.value.endsWith("保留输入")'),true,'offscreen IME commit lost content');
await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;})()`);
@@ -41,6 +56,7 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'native input did not synchronize before copy measurement');
const inputCheck={beforeEditing,duringEditing,offscreenEditing};
console.log('PERF native editing '+JSON.stringify(inputCheck));
// 记录 AI 任务 POST 基数;修改面板设置或复制只能改配置/文档,不应启动生成。
const runStartsBefore=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length;
await wait(`!!document.querySelector('[aria-label="AI 创作"]')&&!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI panel action did not become available');
await evaluate(`document.querySelector('[aria-label="AI 创作"]').click()`);
@@ -48,24 +64,29 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
await evaluate(`(()=>{window.__performancePanel=document.querySelector('[aria-label="AI 指令"]');window.__performancePanel.focus();window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);})()`);
await call('Input.insertText',{text:'Stable host panel draft'});
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 宿主 AI 面板与节点编辑不同:面板持有草稿不应额外保留所有节点。
inputCheck.panelFocusedNodes=await evaluate('document.querySelectorAll(".react-flow__node").length');
if(count>=100)assert.ok(inputCheck.panelFocusedNodes<=beforeEditing+10,'host-panel focus mounted the complete canvas');
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 平移后面板 DOM、焦点和草稿都保留,但画布离屏节点应归零。
assert.equal(await evaluate('window.__performancePanel.isConnected&&document.activeElement===window.__performancePanel&&window.__performancePanel.value==="Stable host panel draft"'),true,'independent panel lost its focus or draft during pan');
inputCheck.offscreenPanel=await evaluate('document.querySelectorAll(".react-flow__node").length');
assert.equal(inputCheck.offscreenPanel,0,'host-panel input pinned unrelated offscreen nodes');
await evaluate('window.__performancePanel.blur();new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'AI settings did not save before copy');
// blur 后从 fixture 服务文档核对配置已经保存,再恢复视口并关闭面板。
assert.equal(documents.get(root).elements.get('perf-0').data.ai?.prompt,'Stable host panel draft','host-panel blur lost saved settings');
await evaluate(`(()=>{document.querySelector('[aria-label="关闭 AI 工具"]').click();const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__performancePanel;delete window.__editViewport;})()`);
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,runStartsBefore,'editing settings started a generation task');
// 通过 elementFromPoint 搜索真正空白 pane 坐标并原生点击,清空之前编辑选择,避免污染 frame 工具栏计数。
const panePoint=await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect();for(let y=rect.y+5;y<rect.bottom;y+=20)for(let x=rect.x+5;x<rect.right;x+=20)if(document.elementFromPoint(x,y)===pane)return{x,y};throw Error('no empty native pane point');})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...panePoint,button:'left',buttons:1,clickCount:1});
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...panePoint,button:'left',buttons:0,clickCount:1});
await wait('document.querySelectorAll(".react-flow__node-toolbar").length===0','native edit selection did not clear before frame selection');
// 选择 frame 后只允许一个工具栏,portal 标题编辑作为新的离屏保留 owner。
await evaluate(`document.querySelector('.react-flow__node[data-id="perf-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await wait(`!!document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'partition selection did not enable deep copy');
await evaluate(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))`);
@@ -76,13 +97,16 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
assert.equal(await evaluate('window.__performancePortal.isConnected&&document.activeElement===window.__performancePortal&&window.__performancePortal.value==="Retained portal title"'),true,'portal title draft was lost during offscreen pan');
// 分区标题 portal 离屏时只保留必要 owner;blur 后应释放全部离屏节点,回到原视口再确认标题保存及重挂载。
inputCheck.offscreenPortal=await evaluate('document.querySelectorAll(".react-flow__node").length');
if(count>=100)assert.ok(inputCheck.offscreenPortal<=10,'one portal draft mounted the complete canvas');
await evaluate('window.__performancePortal.blur();true');
await wait('document.querySelectorAll(".react-flow__node").length===0','released portal retained offscreen owners');
await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;delete window.__performancePortal;})()`);
await wait(`document.querySelector('[aria-label="分区名称"]')?.value==='Retained portal title'&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'portal title did not save and remount');
// 捕获复制开始前的原文,用来证明并发输入不进入已经捕获的副本。
const sourceText=documents.get(root).elements.get('perf-0').data.text;
// 页面脚本以 rAF 采样新 DOM、进度完成、节点峰值、帧间隔,并在支持时观察 longtask;tick 反复采样,read 汇总,stop 取消并断开 observer。
await evaluate(`(()=>{
const originals=new Set(['perf-source','perf-frame',...Array.from({length:${count}},(_,i)=>'perf-'+i)]),start=performance.now(),tasks=[],gaps=[];
let raf,last=start,sawPreparing=false,firstDOM=null,firstFrame=null,localComplete=null,localCompleteFrame=null,prepared=null,maxNodes=0;
@@ -93,11 +117,15 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
window.__canvasPerformance={read(){const sorted=[...gaps].sort((a,b)=>a-b),p95=sorted[Math.max(0,Math.ceil(sorted.length*.95)-1)]??0;return{frameGapP95Ms:p95,elapsedMs:performance.now()-start,firstDOMMs:firstDOM,firstPresentedFrameMs:firstFrame,localCompleteMs:localComplete,localCompleteFrameMs:localCompleteFrame,prepareMs:prepared,maxMountedNodes:maxNodes,nodeCount:document.querySelectorAll('.react-flow__node').length,longTasksSupported:supported,longTaskCount:tasks.length,maxLongTaskMs:Math.max(0,...tasks.map(task=>task.duration)),totalLongTaskMs:tasks.reduce((total,task)=>total+task.duration,0),maxFrameGapMs:Math.max(0,...gaps),longTasks:tasks};},stop(){cancelAnimationFrame(raf);if(observer){tasks.push(...observer.takeRecords().map(entry=>({start:entry.startTime-start,duration:entry.duration})));observer.disconnect();}return this.read();}};
document.querySelector('[aria-label="深复制选中内容"]').click();
})()`);
// 页面脚本安装采样后点击复制;Node 侧此后才启用复制期间的可选堆采样。
copying=true;console.log('PERF copy requested');
// 可选探针通过原生输入继续编辑源卡,校验完整文字增量;副本随后仍须保留复制起点文本。
if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'concurrent typing lost pre-existing source text');console.log('PERF concurrent input '+JSON.stringify(duringCopyInput));}
await wait(`document.querySelectorAll('.react-flow__node').length>0&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 完成进度与已同步后再等两帧,要求 renderer 测量出现合批;统计批量大小而非只计渲染总数。
const internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch');
// 停止页面采样并验证服务元素数、复制 canvas 和上级连线。firstPresentedFrame 是检测新 DOM 后的下一次 rAF 近似值,不是 compositor 上屏证明。
const measurements=await evaluate('window.__canvasPerformance.stop()'),document=documents.get(root),copiedCanvas=[...document.canvases.values()].find(canvas=>canvas.parent&&canvas.id!==partition);
assert.equal(document.elements.size,2*count+5);assert.ok(copiedCanvas);assert.ok([...document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId==='perf-source'&&element.target.elementId===copiedCanvas.parent.entryElementId));assert.ok(document.elements.has('perf-incoming'));assert.ok(measurements.firstPresentedFrameMs!==null&&measurements.localCompleteFrameMs!==null);if(count>=100)assert.ok(measurements.nodeCount<target,'offscreen DOM was not culled');assert.ok(await evaluate(`document.querySelector('[aria-label="画布导入"]').textContent.includes('${count+3} 项')`),'copy progress did not include all source records');
if(concurrentInput){
@@ -105,17 +133,26 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
assert.equal(copiedFirst?.data.text,sourceText,'copy snapshot absorbed a later original edit');
assert.equal(document.elements.get('perf-0').data.text,duringCopyInput.value,'ACK overwrote concurrent original text');
}
// 再次核对整个配置/复制场景没有启动 AI 任务;生成计费不属于深复制的副作用。
const runStartsDuringScenario=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length-runStartsBefore;
assert.equal(runStartsDuringScenario,0,'deep copy or settings unexpectedly started generation');
// 离屏源仍需保留两条可见分区连线,随后把最后一个副本节点平移到屏幕内,检查实际挂载。
const visibleEdges=await evaluate('document.querySelectorAll(".react-flow__edge").length');assert.equal(visibleEdges,2,'frame connections did not render for the offscreen source');
// Completion is a model/progress check. Verify an offscreen copied card actually mounts after panning.
// 进度完成只证明模型/作业状态;还要平移后看到副本卡片实际挂载。
// 按原节点局部位置找到复制后的末项,而非只依赖进度或服务记录宣称渲染完成。
const originalLast=document.elements.get(`perf-${count-1}`),copiedLast=[...document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId===copiedCanvas.id&&element.geometry.x===originalLast.geometry.x&&element.geometry.y===originalLast.geometry.y);assert.ok(copiedLast);
// 从副本 owner 的局部坐标换算世界中心,给页面 viewport 平移定位使用。
const world=toWorld(canvasTransform(document,copiedLast.ownerCanvasId),{x:copiedLast.geometry.x+copiedLast.geometry.width/2,y:copiedLast.geometry.y+copiedLast.geometry.height/2});
await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect(),matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(${world.x}*matrix.a+matrix.e-rect.width/2)/.5,deltaY:(${world.y}*matrix.a+matrix.f-rect.height/2)/.5,bubbles:true,cancelable:true}));})()`);
await wait(`!!document.querySelector('.react-flow__node[data-id="${copiedLast.id}"] textarea')`,'offscreen copied card did not mount after viewport pan');
// 记录平移后的实际挂载数量,和之前的可见连线数一起写入报告。
const viewportCheck={visibleEdgesBeforePan:visibleEdges,copiedId:copiedLast.id,mountedAfterPan:await evaluate('document.querySelectorAll(".react-flow__node").length')};
// 报告同时保存输入断言上下文、浏览器/构建模式、采样、fixture 文档计数与测量批次,便于跨环境比较。
report={inputCheck,duringCopyInput,runStartsDuringScenario,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} ${bundler}; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks`,count,initialReadyMs,buildMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started};
// 仅成功路径截图,输出到报告同名 PNG;截图是场景结束视口,不是每个计时点的屏幕证据。
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile(reportPath.endsWith('.json')?reportPath.slice(0,-5)+'.png':reportPath+'.png',Buffer.from(screenshot.data,'base64'));
// 失败也尽力停止页面采样并写诊断报告,然后重抛;不能把部分观测输出当作场景通过。
}catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',count,buildMode,buildMs,error:error.message,measurements,serverElements:documents.get(root)?.elements.size,serverCanvases:documents.get(root)?.canvases.size,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;}
// 成功报告落盘后打印紧凑摘要,长任务明细仍保存在 JSON 文件。
await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath);
}
+12
View File
@@ -1,30 +1,42 @@
/**
* 渲染定义目录与 React 组装的契约测试。模型 registry 定义数据与能力,renderer catalog 定义宿主如何显示,两者按类型/版本/结构匹配。
* 这里检查描述捕获、投影记录与函数身份,不挂载真实 ReactFlow 页面。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRendererCatalog,builtinRendererDefinitions} from '../../src/lib/canvas-bindings/renderers.ts';
import {createReactRenderers} from '../../src/lib/canvas-bindings/react-renderers.ts';
import {createDocument,createRegistry,builtinComponents,defineComponent,movable,createEditor} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
// 自定义计量节点的精确渲染键,不允许用同一映射自动兼容未来版本或不同 structure。
const descriptor={componentId:'plugin:meter',dataVersion:1,structure:'node',rendererId:'meter'};
// 模型独立声明 metric 校验和移动能力;同一个模型可由多个宿主使用不同渲染器。
const definition=defineComponent({id:'plugin:meter',dataVersion:1,structure:'node',validate:data=>typeof data.metric==='number',features:[movable()]}),registry=createRegistry([...builtinComponents(),definition]);
// 保留不透明标题和看似内部字段的数据,用来核对投影注入的可信原记录不会被用户 data 冒充。
const node={id:'meter',incarnation:1,ownerCanvasId:'root',structure:'node',componentId:'plugin:meter',dataVersion:1,data:{metric:7,title:{opaque:true},__headlessRecord:{untrusted:true}},geometry:{x:0,y:0,width:200,height:100,rotation:0}};
// 目录只捕获不可变元数据,不持有 React render 值;输入描述后续变化无效,未来版本/不同结构不匹配,重复或保留 ID 被拒绝。
test('renderer catalog captures exact version/structure mappings without React values or mutable descriptors',()=>{
const input={...descriptor,render(){}};const catalog=createRendererCatalog([input]);input.rendererId='changed';assert.equal(catalog.resolve(node).rendererId,'meter');assert.equal(catalog.resolve({...node,dataVersion:2}),undefined);assert.equal(catalog.resolve({...node,structure:'connection'}),undefined);assert.equal(catalog.definitions[0].render,undefined);assert.ok(Object.isFrozen(catalog.definitions[0]));assert.throws(()=>createRendererCatalog([descriptor,descriptor]),/duplicate_renderer_definition/);assert.throws(()=>createRendererCatalog([{...descriptor,rendererId:'unsupported'}]),/invalid_renderer_definition/);
});
// 组装仅在同一个 render 实现时共享别名包装;拒绝空 renderer、ID 冲突与模型契约不符,getter 只读取一次并保留 memo 对象。
test('React assembly preserves aliases only for the same component and rejects invalid renderers or model contracts',()=>{
const render=()=>null,assembly=createReactRenderers([{...descriptor,render}],registry);assert.equal(assembly.nodeTypes.meter.type,render);assert.ok(Object.isFrozen(assembly.nodeTypes));assert.equal(assembly.edgeTypes.meter,undefined);
assert.throws(()=>createReactRenderers([{...descriptor,render:null}]),/invalid_react_renderer/);assert.throws(()=>createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render:()=>null}]),/renderer_id_conflict/);assert.throws(()=>createReactRenderers([{...descriptor,dataVersion:2,render}],registry),/renderer_model_mismatch/);
let reads=0;const getter=createReactRenderers([{...descriptor,get render(){reads++;return render;}}]);assert.equal(reads,1);assert.equal(getter.definitions[0].render,render);assert.equal(getter.nodeTypes.meter,assembly.nodeTypes.meter);
const alias=createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render}]);assert.equal(alias.nodeTypes.meter,assembly.nodeTypes.meter);const memo={$$typeof:Symbol.for('react.memo'),type:render};assert.equal(createReactRenderers([{...descriptor,render:memo}]).nodeTypes.meter,memo);
});
// 自定义节点与边投影携带原记录和 transform,未知 data/path 不丢;未安装映射或未来 schema 走 unsupported。
test('installed node and edge renderers receive original records and transforms while absent schemas still fall back',()=>{
const customEdge=defineComponent({id:'plugin:edge',dataVersion:1,structure:'connection',validate:()=>true,features:[]}),r=createRegistry([...builtinComponents(),definition,customEdge]),other={...node,id:'second',geometry:{...node.geometry,x:300}},edge={id:'edge',incarnation:1,structure:'connection',componentId:'plugin:edge',dataVersion:1,ownerCanvasId:'root',source:{elementId:'meter',portId:'custom-out'},target:{elementId:'second',portId:'custom-in'},path:{type:'custom',retained:true},data:{path:{own:true},metric:99}},source=createDocument('project',[{id:'root',parent:null}],[node,other,edge]),catalog=createRendererCatalog([...builtinRendererDefinitions,descriptor,{...descriptor,componentId:'plugin:edge',structure:'connection',rendererId:'meter-edge'}]),p=projectCanvas(source,'root',r,new Set(),()=>undefined,null,catalog);
assert.equal(p.nodes[0].type,'meter');assert.equal(p.nodes[0].data.__headlessFallback,undefined);assert.equal(p.nodes[0].data.__headlessRecord,source.elements.get('meter'));assert.deepEqual(p.nodes[0].data.__headlessRecord.data.title,{opaque:true});assert.equal(p.nodes[0].data.__headlessTransform.scale,1);assert.equal(p.edges[0].type,'meter-edge');assert.equal(p.edges[0].data.__headlessRecord,source.elements.get('edge'));assert.deepEqual(p.edges[0].data.__headlessRecord.path,{type:'custom',retained:true});
const missing=projectCanvas(source,'root',r,new Set(),()=>undefined);assert.equal(missing.nodes[0].type,'unsupported');assert.equal(missing.edges[0].type,'unsupported');const future=createDocument('project',[{id:'root',parent:null}],[{...node,dataVersion:2}]);assert.equal(projectCanvas(future,'root',r,new Set(),()=>undefined,null,catalog).nodes[0].type,'unsupported');
});
// 加载前预览必须显式提供 preview,实现与正式 renderer 独立捕获;非法 preview 或不一致别名被拒绝。
test('pre-engine preview renderers are explicit and captured independently from engine-bound renderers',()=>{
const render=()=>null,preview=()=>null,assembly=createReactRenderers([{...descriptor,render,preview}]);assert.equal(assembly.nodeTypes.meter.type,render);assert.equal(assembly.previewNodeTypes.meter.type,preview);assert.equal(assembly.definitions[0].preview,preview);assert.ok(Object.isFrozen(assembly.previewNodeTypes));assert.equal(createReactRenderers([{...descriptor,render}]).previewNodeTypes.meter,undefined);assert.throws(()=>createReactRenderers([{...descriptor,render,preview:0}]),/invalid_react_preview/);assert.throws(()=>createReactRenderers([{...descriptor,render,preview},{...descriptor,componentId:'other',render}]),/renderer_id_conflict/);
});
// 两个宿主对同一 component 选择不同 rendererId,但共享同一文档记录与移动结果,命令仍只产生一个历史单元。
test('independent host renderers for one component share model data and capability behavior',()=>{
const editor=createEditor({registry,document:createDocument('project',[{id:'root',parent:null}],[node])}),one=createReactRenderers([{...descriptor,rendererId:'host-one',render:()=>null}],registry),two=createReactRenderers([{...descriptor,rendererId:'host-two',render:()=>null}],registry);
assert.equal(editor.dispatch({type:'element.geometry',target:{id:node.id,incarnation:node.incarnation},patch:{x:42}}).status,'applied');
@@ -1,21 +1,30 @@
/**
* 资源引用扫描测试:工作文档、ACK、历史及显式 owner 都可能持有资源;无法解释的数据必须让扫描 complete=false。
* 缓存只复用不可变历史范围的结构事实,不能缓存外部可变表成员,也不能忽略当前 registry 的资源字段定义。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,describeHistoryTransition} from '../../src/lib/canvas-core/index.ts';
import {createResourcePinScan,resourcePins} from '../../src/lib/canvas-runtime/resources.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);
// 用 Proxy 计数证明不可变历史范围只准备一次;working/ACK 分别扫描,最终 pin 保留各自来源而不混淆。
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 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',()=>{
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',()=>{
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',()=>{
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}}]])};
+14
View File
@@ -1,40 +1,54 @@
/**
* 资源显示订阅的独立生命周期回归。通过手动Promise控制读取完成顺序,检查逐项显示、部分失败重试、替换取消及句柄释放。
* blob字符串仅为测试URL,release计数由桩实现;不运行真实资源解码或浏览器URL仓库。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
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 }; };
/** 等一个事件循环轮次,让资源Promise与通知完成,避免用固定墙钟延迟猜时序。 */
const tick = () => new Promise(resolve => setImmediate(resolve));
/** 收集resolve请求、onChange、onError及release序号;返回view与手动完成入口。 */
function fixture() {
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) });
/** 解析指定请求为URL句柄,release幂等记录一次,便于核对替换/卸载是否漏清理或重复释放。 */
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) };
}
// r1先就绪就显示,不等待r2;新增r3不重读旧资源或释放已就绪句柄,最终dispose各释放一次。
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();
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.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 () => {
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);
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();
});
// 同节点由old切new,旧signal被取消;旧实现忽略abort后才返回,也只释放旧句柄,不能覆盖新URL。
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);
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]);
});
// 删a只释放a,b保持;两次dispose只各清一次,已dispose的retry明确拒绝。
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();
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' });
});
// dispose后资源才到,仍释放句柄但不再onChange/onError,避免旧视图收到取消噪音。
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();
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 () => {
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, []);
+76 -1
View File
@@ -1,3 +1,9 @@
/**
* 项目引擎资源生命周期测试:本地图片先发布,文件及账本持久化后上传,依赖文档操作等待资源确认。
* 默认尺寸授权、解码元数据和显式 fitImage 各有独立语义;迟到解码不能覆盖用户调整或绑定到旧元素身份。
* 本地回收需综合 working/history/ACK/任务/显示引用,先标记、再等待宽限期,并将物理删除与恢复墓碑一同保存。
* fixture 使用内存端口,验证引擎协调及故障处理;不证明真实数据库的事务隔离或浏览器实际解码能力。
*/
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
import { test } from 'node:test';
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 { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
// 内置 registry 提供图片资源字段与几何能力;固定作用域让重开时读取同一个项目的持久化 image。
const registry = createRegistry(builtinComponents());
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' };
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 } });
// 初始只有 canvas 和 a,新增 picture 可通过元素数量及服务端写次数区分,避免把恢复误当再次创建。
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 }; };
// 最多轮询 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'); }
// 为每个用例创建文件/提交/回执表和假服务端;storage 与服务端保留状态以支持关闭后重新打开引擎。
function fixture() {
let image = null, owner, counter = 0, server = original, version = 1;
const files = new Map(), batches = [], uploads = [], writes = [], receipts = new Map(), assetReceipts = new Map();
const storage = {
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; },
// 克隆先前 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 readFile(_leader, id) { return files.get(id); }, close() {},
// 按资源 ID 取原始 Blob;用例会替换它来制造磁盘错误、共享读取及迟到结果。
async readFile(_leader, id) { return files.get(id); }, /* 内存存储无外部句柄;owner 由 release 归还。 */ close() {},
};
const assets = {
sourceURL: id => `https://assets.example/${id}`, displayURL: id => `/assets/${id}`,
// 上传前断言文件已落盘、image 中已有 dispatch-marked 证据;随后保存可查询的成功资产回执。
async upload(request, bytes) {
// 资源字节和 dispatch 证据都必须在上传前可恢复;这两条断言验证持久化屏障的顺序。
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'));
uploads.push(request);
@@ -33,20 +51,29 @@ function fixture() {
assetReceipts.set(request.id, 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' }; },
// 此 fixture 直接交出 canonical,由引擎继续验证资源身份;Foundry wire 回执校验在 adapter 专项测试中覆盖。
accept: (_request, status) => status.canonical,
};
const persistence = {
// 恢复读取当前假服务端文档和 canvas 版本,不能直接拿本地 working 代替 ACK。
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)])) }; },
// 文档写入应用 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 query(request) { return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved' }; },
// 成功文档请求取其保存的 document/versions,不凭当前本地文档猜测服务端状态。
canonical(request) { return receipts.get(request.id); },
};
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 }),
};
}
// stageFiles 在同一同步事务创建图片与资源工作;defaultSize 决定是否授予后续自动尺寸修正权限。
function addImage(engine, id = 'picture', defaultSize = false) {
let resource;
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;
}
// 解码前先移动和改名,自动尺寸结果随后回写创建历史且不增加 undo 单元;重开后改名和移动可分别撤销,尺寸随创建保留,解码和上传均只一次。
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 images = { decode: async () => { calls++; entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } };
let engine = await f.open({ images });
try {
const ref = addImage(engine, 'picture', true); await entered.promise;
// 订阅只记录尺寸已经生效的发布,随后核对 grant 与创建历史是否在同一 Root 中同步变化。
const observations = [];
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 };
engine.editor.dispatch({ type: 'element.geometry', target, patch: { x: 70 } });
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 root = engine.getSnapshot(); assert.equal(root.document.elements.get('picture').geometry.height, 50);
assert.equal(root.document.elements.get('picture').geometry.x, 70); assert.deepEqual(root.history.undo, undo);
assert.equal(root.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
assert.ok(observations.length > 0);
// 不能先发尺寸、后补 history:任何被观察到的生效快照都要具备对应授权终态与补偿证据。
for (const snapshot of observations) {
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.operations.values()].some(intent => intent.historyEffect), true);
}
unsubscribe();
// 自动尺寸是 asset-resolution 效果,关联历史证据;资源含新字段时不能把 image 格式降级为旧版本。
const effect = [...root.operations.values()].find(intent => intent.historyEffect);
assert.equal(effect.origin, 'asset-resolution'); assert.equal(effect.historyTransition.entries.length, 2);
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(); }
});
// 用户已提交 resize 即永久撤销默认尺寸授权;即使在解码完成前 undo resize,也不能重新授权自动调整。
test('a committed user resize permanently revokes default sizing even if undone before decoding', async () => {
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
try {
@@ -108,6 +141,7 @@ test('a committed user resize permanently revokes default sizing even if undone
} finally { gate.resolve(); await engine.close(); }
});
// 关闭时解码尚未完成,持久化保留 pending grant 和源字节;重开用新解码器继续修正,创建仍是一个历史单元。
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 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(); }
});
// 手势预览期间暂不发布默认尺寸;提交移动后再修正高度,保留移动位置,undo 移动不会撤销尺寸补偿。
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 }; } } });
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(); }
});
// 图片被换成另一资源后,旧 grant 撤销;旧解码器迟到完成不能更改当前几何。
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 }; } } });
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(); }
});
// 策略拒绝自动 geometry 命令时保留 blocked 授权;显式 retry 消费原 grant,仍只有一个创建历史和一次上传。
test('a denied automatic resize records a recoverable block and explicit retry consumes the original grant', async () => {
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 }; } });
@@ -159,6 +196,7 @@ test('a denied automatic resize records a recoverable block and explicit retry c
} finally { await engine.close(); }
});
// 未来版本 sizing 作为不透明数据保留,资源扫描不再声称完整;无关改名仍可保存,不能把未来授权当已知规则运行。
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 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(); }
});
// stageFiles 立即显示元素与历史,字节和恢复状态同批保存;上传挂起时无关编辑可先同步,复制/撤销复用资源不重复上传。
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;
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' } });
await until(() => f.writes.length === 1);
assert.equal(f.uploads.length, 0);
// 检查实际交给 storage 的批次,而非只看当前内存状态,证明文件与图片内容同 cut 保存。
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.runtime.resources.length, 1);
@@ -202,6 +242,7 @@ test('pending images publish immediately, bytes share the durable cut, and indep
await engine.close();
});
// 上传服务已成功但响应丢失,依赖图片文档仍等待;重开查询同一冻结 ID/body,恢复原资源后只提交一次图片文档。
test('lost upload acknowledgment recovers original byte identity and query receipt without recreating a node', async () => {
const f = fixture(), upload = f.assets.upload;
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 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);
// 破坏上传 operationId 的交叉引用应使恢复拒绝,避免把正确字节绑到另一上传工作。
const bad = structuredClone(f.image()); bad.payload.runtime.resources[0][1].operationId = 'not-the-upload';
assert.throws(() => decodeRecovery(bad, registry), /corrupt_recovery_image/);
await engine.close();
// 重开时让时钟超过重试截止时间,要求先查原回执,不创建新上传请求或新节点。
const queries = [], query = f.assets.query;
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) } });
@@ -223,6 +266,7 @@ test('lost upload acknowledgment recovers original byte identity and query recei
await recovered.close();
});
// 替换图片在本地立即可见,上传完成前服务端保留旧引用;undo/redo 和重开只切换引用,不重复上传或更换元素身份。
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;
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();}
});
// 创建重复 ID 被拒或回调为空时,不留下文件字节、资源记录或上传任务;失败事务不能泄漏已暂存工作。
test('rejected and empty staging do not retain file bytes or create upload work', async () => {
const f = fixture(), engine = await f.open();
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();
});
// 未知组件的资源 schema 使扫描不完整;明确注册的额外 owner 仍可单独保留已知资源。
test('unknown resource reference schemas prevent claiming complete garbage-collection coverage', async () => {
const f = fixture(), engine = await f.open();
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();
});
// 上传回执声称成功但 assetId 错误时,保留原回执及 waiting 请求,不释放文档依赖,也不能把错误资源标为 stored。
test('invalid applied asset metadata retains the receipt and pending document without releasing the dependency', async () => {
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' } }; };
const errors = [], engine = await f.open({ onError: error => errors.push(error) });
const ref = addImage(engine); await engine.settle();
// applied 是服务端执行事实,waiting 是本地尚未接纳:错误元数据不能把事实改回未执行,也不能解锁后续文档。
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(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();
});
// 资源和 image 的首次持久化失败后,本地图片仍可见,但上传与文档均不发送;关闭也报错,修复存储再完成可靠保存。
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) });
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(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id);
await assert.rejects(() => engine.close(), /quota/);
// 关闭失败不丢弃待保存成果;恢复原端口后再次关闭完成持久化,随后检查文件和历史。
f.storage.commit = commit; await engine.close();
assert.equal(await f.files.get(ref.id).text(), 'image bytes');
assert.equal(decodeRecovery(f.image(), registry).history.undo.length, 1);
assert.ok(errors.length);
});
// 服务端上传已成功、accept 暂时失败时,通过恢复会话接纳 stored 身份并释放依赖文档;history 和冻结字节不变,无二次上传。
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 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()=>{
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();}
});
// 墙上时间可手动推进,重试定时器仍用真实 setTimeout;用于精确检查回收宽限期。
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();}
// 无引用资源先保存 soft marker,再满 100ms 宽限期后把文件删除和 purge 墓碑一起保存;重开可用远端 URL,不重新上传。
test('local collection persists a soft marker, waits retention and atomically deletes bytes with a v3 recovery tombstone',async()=>{
const f=fixture(),clock=resourceClock();let engine=await f.open({historyLimit:1,clock});const ref=addImage(engine);try{
await engine.settle();await unreferenceImage(engine);assert.equal(engine.getResourcePins().pins.has(ref.id),false);
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);
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);
// 用保存过的 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 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/);}
@@ -320,6 +377,7 @@ test('local collection persists a soft marker, waits retention and atomically de
}finally{await engine.close();}
});
// 文档、历史、显式 pin 或未知覆盖均阻止删除;资源重新被引用时清除退休标记,之后再次无引用需重新计时。
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{
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();}
});
// display 在异步磁盘读取前就取得 pin 并清除退休状态;句柄重复 release 安全,最后释放后重新启动回收宽限期。
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{
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();}
});
// 两个 reader 共用一次磁盘读取及 URL;取消其中一个不释放另一个的 pin,直到最后句柄 release 才撤销 URL 并允许回收。
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 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);
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); };
// 两个调用各自持有 reader 生命周期;共享底层读取不能让一个 AbortSignal 取消另一个调用。
const cancel = new AbortController(), first = engine.displayResource(ref.id, { signal: cancel.signal }), second = engine.displayResource(ref.id);
await entered.promise; assert.equal(reads, 1);
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, []);
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);
// release 幂等且按句柄计数;另一个句柄仍在时不能撤销共享 URL 或移除字节保护。
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);
assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, [ref.id]);
} 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 () => {
const f = fixture(), engine = await f.open(), ref = addImage(engine), gate = deferred(), entered = deferred(), read = f.storage.readFile, createURL = URL.createObjectURL;
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; }
});
// 磁盘读取错误保留原错误码,失败 reader 释放显示 pin;恢复端口后新 reader 可重试而不再次上传。
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;
try {
@@ -381,6 +445,7 @@ test('display failures retain their cause and a fresh reader can retry without u
} 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 () => {
const f = fixture(); let current = true;
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; }
});
// 物理删除落盘期间预留目标资源,禁止再引用或 display 该资源;无关元素编辑保持可用。
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{
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();}
});
// 删除落盘失败后保留字节及 reservation,旧持久化 image 没有 purge 墓碑;显式 persist 成功后才删除并释放,显示回退远端 URL。
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{
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();}
});
// 订阅者在 purge 发布回调内同步调用 persist,也不能把 purge 墓碑先于 deleteFiles 单独保存。
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{
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');
}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()=>{
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'})])}};
@@ -433,20 +503,25 @@ test('retention covers ACK-only content, opaque job envelopes and historical fie
}finally{await engine.close();}
});
// 普通解码完成只发布元数据,不覆盖用户新几何;显式 fitImage 才按比例改尺寸并增加一个正常 undo 单元。
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);
}finally{gate.resolve();await engine.close();}
});
// 解码失败不阻塞资源上传;retry 只重新解码并保持资源、元素、上传及历史身份。
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();}
});
// 资源元数据可跨元素删除/恢复继续使用,但 fitImage 必须匹配当前 incarnation 与 assetId,旧身份或替代资源都拒绝。
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();}
});
// 恢复预览 hold 期间解码虽完成也不能发布新状态根;取消会话释放协调边界后才接纳 ready 元数据。
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();}
});
// 未来版本 image 元数据不触发重解码或静默迁移,原样持久化并阻止完整 GC 声明,仍允许无关编辑。
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();}
});

Some files were not shown because too many files have changed in this diff Show More