43 Commits

Author SHA1 Message Date
codex-bot cda4db5ec8 docs(config): explain package settings and generated JSON contracts 2026-11-12 12:12:22 +08:00
codex-bot fe80df375c docs(styles): explain global page sections and responsive cascade 2026-11-12 12:07:45 +08:00
codex-bot 4f75dcb9fd docs(styles): explain canvas and workspace layout and interaction states 2026-11-12 12:02:26 +08:00
codex-bot 2a0b6bb9d9 docs(tests): explain legacy canvas scenario setup and recovery flows 2026-11-12 11:57:00 +08:00
codex-bot 412e31e898 docs(tests): explain legacy canvas and workspace unit coverage 2026-11-12 11:51:14 +08:00
codex-bot 6ad003eb37 docs(tests): explain renderer recovery AI import and retained ownership flows 2026-11-12 11:43:08 +08:00
codex-bot 50675ffed3 docs(tests): explain renderer fixture setup proxy routes and native gestures 2026-11-12 11:36:18 +08:00
codex-bot bd728d2d94 docs(tests): explain storage collection import recovery and loading barriers 2026-11-12 11:29:07 +08:00
Zayden-Jung 6298202dd7 Merge pull request 'docs(tests): 说明浏览器驱动、React fixture 与渲染探针' (#10) from codex/browser-fixture-comments into main
Reviewed-on: #10
2026-11-12 03:26:06 +00:00
codex-bot f08310b83c docs(tests): explain browser storage history recovery and suite orchestration 2026-11-12 11:21:50 +08:00
codex-bot e0e4276e71 docs(tests): explain browser drivers React fixtures and renderer probes 2026-11-12 11:13:08 +08:00
Zayden-Jung 167e01af18 Merge pull request 'docs(tests): 说明画布运行时、恢复和集成测试' (#9) from codex/worker-test-comments into main
Reviewed-on: #9
2026-11-12 03:06:28 +00: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
codex-bot 1c832988a2 docs(canvas): explain legacy editor tools and subgraph interactions 2026-11-12 02:21:27 +08:00
codex-bot 95e8a57019 docs(canvas): explain headless view subscriptions and interaction adapters 2026-11-12 02:17:32 +08:00
Zayden-Jung 6b74629d98 Merge pull request 'docs(canvas): 说明旧画布树状态与事务协调' (#4) from codex/canvas-tree-comments into main
Reviewed-on: #4
2026-11-11 18:16:16 +00:00
codex-bot 33f5c076f6 docs(canvas): explain legacy tree state and transaction coordination 2026-11-12 02:13:31 +08:00
Zayden-Jung bcf810998d Merge pull request 'docs(canvas): 补充加载预览与旧任务 hooks 注释' (#3) from codex/canvas-panel-comments into main
Reviewed-on: #3
2026-11-11 18:10:50 +00:00
Zayden-Jung fb737eeb0b Merge pull request 'docs(canvas): 补充任务面板注释并恢复 PR 交付' (#2) from codex/canvas-panel-comments into main
Reviewed-on: #2
2026-11-11 18:07:38 +00:00
148 changed files with 3927 additions and 94 deletions
+2
View File
@@ -1,2 +1,4 @@
# Next 服务端访问 Foundry 的源站;本地代理从这里发起后端 HTTP 请求,不是浏览器页面地址。
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
# 公开的项目路由标识,可进入浏览器构建;不是密码、会话令牌或授权凭证。
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
+7
View File
@@ -1,8 +1,15 @@
# 安装依赖不入库;依赖版本由包清单与 lock 文件记录。
node_modules/
# Next 开发/生产构建缓存与产物,不作为源代码提交。
.next/
# 独立 headless ESM 包由 scripts/build-headless.mjs 重建。
.headless-dist/
# 忽略本机环境变量文件;下一条只放行可公开的示例模板。
.env*
!.env.example
# TypeScript 增量检查缓存,由工具重新生成。
*.tsbuildinfo
# macOS Finder 的目录元数据。
.DS_Store
# 仓库根 docs 是本地研究/草案目录,当前约定不跟踪其中内容。
/docs/
+70
View File
@@ -142,3 +142,73 @@ Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。
## 配置与生成数据阅读说明
本节为不适合内嵌注释的配置和严格 JSON 提供旁注;实际值仍以对应文件为准。源码、脚本和测试中的函数说明保留在各自文件内。包版本、TypeScript 输出目标、服务协议版本、文档格式版本和组件数据版本是不同概念。
### 应用包与执行入口
根 `package.json` 的 `private: true` 防止常规 npm 发布,`version: 0.1.0` 是应用包版本。`dependencies` 是应用运行/构建引用的库,`devDependencies` 包含编译器、类型声明和 Tailwind 工具;它们的精确版本及传递依赖由 `package-lock.json` 锁定。lock 文件由包管理器维护,不在其中手写说明或改某个嵌套版本。
| scripts 项 | 执行内容与边界 |
| --- | --- |
| `dev` | 在 localhost:17321 启动 Next 开发服务器,会按需编译。 |
| `build` / `start` | 前者执行 Next 生产构建,后者在 localhost:17321 运行已有构建。 |
| `typecheck` | `tsc --noEmit` 检查类型,不生成应用 JS。 |
| `test:headless` | 运行 `tests/headless/*.test.mjs`;不自动执行所有 `.integration.mjs` 或浏览器场景。 |
| `test:headless:browser` | 浏览器存储、Worker 等适配器场景入口,环境装配见 `browser-storage.mjs`。 |
| `test:headless:proxy` | Foundry 同源代理的隔离集成入口,启动与替身链路见 `proxy.integration.mjs`。 |
| `test:headless:renderer` / `test:headless:performance` | 运行页面集成入口;后者附加 `--performance` 选择性能场景。 |
| `build:headless` | 清理并重建 `.headless-dist` 的 ESM、声明和 Worker 引用,再复制包清单/文档。 |
| `test:headless:package` | 构建、打包到隔离目录并运行消费方及浏览器验收,不是单纯语法检查。 |
集成入口可能编译或启动本地进程,具体动作见各脚本模块注释;本节列出命令不表示这些检查已经运行或通过。
### 应用 TypeScript 配置
`tsconfig.json` 面向应用检查;以下选项共同作用,并非运行时开关:
| 配置项 | 用途 |
| --- | --- |
| `target: ES2020` / `lib` | 声明语言输出目标和可用类型库;`dom`、`dom.iterable`、`esnext` 提供 API 类型,不会安装浏览器 polyfill。 |
| `allowJs` / `skipLibCheck` | 允许 JS 进入项目;跳过声明文件内部的完整检查。它们不关闭项目 TS 源码的检查。 |
| `strict` / `noEmit` | 启用严格类型检查,默认只检查而不生成 JS/声明。 |
| `allowImportingTsExtensions` | 允许源码相对导入使用 `.ts` 等 TS 扩展名;独立包输出由另一配置重写扩展名。 |
| `esModuleInterop` | 为 CommonJS/ESM 互操作提供 TypeScript 的兼容处理,不把整个项目改成 CommonJS。 |
| `module: esnext` / `moduleResolution: bundler` | 使用 ESM 语法并按 bundler 模式解析依赖;应用最终交由 Next 构建链处理。 |
| `resolveJsonModule` | 允许 TS 引用并推断 JSON 模块,例如图标 manifest。 |
| `isolatedModules` / `jsx: react-jsx` | 按单文件转换约束检查源码,并使用 React 自动 JSX 转换形式。 |
| `incremental` | 允许 TypeScript 使用增量检查缓存;生成的 `*.tsbuildinfo` 被忽略。 |
| `paths` | `@/*` 在 TS 解析时指向 `src/*`,这不是把字符串路径写进业务文档。 |
| `plugins` | 登记 Next TypeScript 插件供支持它的工具使用。 |
| `include` / `exclude` | 纳入应用 TS/TSX、`next-env.d.ts` 与 Next 生成类型入口;排除依赖和独立包输出目录。由这些源码导入的依赖仍可参与类型解析。 |
`next-env.d.ts` 是 Next 维护的类型入口,引用框架/图片和生成路由类型。其路径会随开发/构建环境变化,不在这个生成文件中追加维护注释。
### 独立 headless 包
`tsconfig.headless.json` 继承应用配置,显式覆盖输出部分:`target`/`module` 为 ES2022,`rootDir` 为 `src/lib`,`outDir` 为 `.headless-dist/modules`;`noEmit: false` 与 `declaration: true` 生成 JS 和 `.d.ts`。`incremental: false` 不使用这次编译的增量缓存,`rewriteRelativeImportExtensions` 将可重写的相对 TS 导入转成输出扩展名。`types: []` 和 `plugins: []` 清空自动纳入的类型包列表与继承插件条目,不禁止源码显式导入类型;未覆盖的 `lib` 等项仍继承。四组 `include` 从 core/runtime/service/bindings 的 `.ts` 入口收集源码,导入关系还会带入所需依赖。
`headless/package.json` 是复制到产物根的包模板,源目录本身没有生成后的 `modules`。它的 `type: module` 声明 ESM,Node 引擎要求为 `>=22`。`exports` 为每个公开入口分别指定类型声明和 ESM 文件:根及 `/core` 指向 core,另有 `/runtime`、`/browser`、`/service`、`/bindings`、`/react-flow` 与 `/react`。这些是产物目录中的路径,不是指向 `src` 的别名,也没有声明 CommonJS `require` 入口。
`files` 限定包收录的模块、消费示例和验收文档。React、React DOM、React Flow 以带版本范围的可选 peer 声明,供选择相关适配入口的宿主提供;“可选”不表示调用 UI 适配器时可以缺少它依赖的框架。便携入口的使用和依赖边界见 `headless/README.md`。`private: true` 保持预览包私有,不代表已经发布到 registry。
### 组件生成器配置
`components.json` 是 UI 组件生成器读取的配置,不是应用运行时组件 registry。`$schema` 指向配置的 schema;`style: new-york`、`tsx: true` 和 `rsc: false` 记录生成风格、TSX 和 RSC 选项,不改变整个 App Router 的路由/渲染模式。`tailwind.css` 指向 `src/styles/globals.css`,空 `tailwind.config` 没有指定独立配置路径,`baseColor: neutral` 与 `cssVariables: true` 是生成器的配色约定。`aliases` 指示生成文件的组件、工具、UI、lib 和 hooks 导入位置;其中 `@/hooks` 不保证已经存在对应目录。
### 图标生成数据
`src/components/canvas/icon-catalog-data.json` 是检索目录数组,每项包含 `name`(持久引用的图标 ID)、`slug`、`source`、`label`、`categories`、`common` 和 `keywords`;不包含 SVG 路径。`icon-catalog.ts` 用它构建名称 Map 并执行筛选,选择器的虚拟窗口另由 UI 控制。
`src/lib/canvas-icon-assets.json` 保存 `root` URL 前缀与 `assets` 桶名到摘要文件名的映射。`iconAsset()` 对 Lucide 名称做 31 进制滚动散列并模 96,Tabler 名称统一使用 `tabler` 桶。`public/canvas-icons/<版本目录>/*.json` 每个分片以图标名索引 `[tag, props][]`,消费端 `validateIconShard()` 再检查支持的图形与属性。
这三类数据由 `scripts/generate-canvas-icons.mjs` 同批生成,中文标注来自 `scripts/canvas-icon-labels.mjs`。文件名摘要是紧凑 JSON 内容的 SHA-256 前 12 位,写盘时另加换行;它与分桶散列用途不同。分片变更须同步目录/manifest 并更新摘要名;脚本不会自动删除旧分片。不要在 JSON 中插入注释或只手改其中一份,否则会破坏读取、引用或缓存命名约定。
### 协议样例与忽略规则
`tests/headless/fixtures/foundry-model-operation-v2.json` 是服务适配器单测的完整请求样例。协议 2 的 `request_id` 标识冻结请求,`expected_versions` 是参与 canvas 的已确认版本,`operations[].id` 是逻辑操作 ID。样例把第一代元素 `a` 的 `data.title` 从 `a` 改为 `renamed`;`beforePresent`/`afterPresent` 区分字段缺失与有值。字段旁注与断言在 `tests/headless/service.test.mjs`,静态样例不代表真实服务已执行。
`.gitignore` 排除依赖、Next/headless 产物、环境变量、增量缓存和系统文件;仅放行 `.env.example` 作为模板。根 `/docs/` 当前作为本地研究目录忽略。忽略规则只影响未跟踪文件,不会把已经跟踪的文件自动移出 Git。`.env.example` 的公开项目标识与服务端源站说明写在模板内;实际凭据不应写进示例或公开构建变量。
+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: "灯泡 灵感" };
+21
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,13 @@ for (const [key, nodes] of shards) {
await writeFile(`${assetDir}/${filename}`, content + "\n");
totalBytes += Buffer.byteLength(content);
}
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
// catalog 每项是 name/slug/source/label/categories/common/keywords;不带 SVG node,搜索只读取这些元数据。
// manifest.root 为公开 URL 前缀,assets 将 lucide-N 或 tabler 逻辑桶映射到带内容摘要的 JSON 文件名。
// 分片对象按图标 name 索引 [tag, props][];props 为可序列化 SVG 属性,消费端还会执行标签/属性校验。
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},
+66
View File
@@ -1,91 +1,124 @@
/* 画布与节点的局部样式:React Flow 全局类通过 :global 组合,状态类由组件/手势控制器切换;CSS 只控制呈现和命中,不负责数据归属、提交或锁。 */
/* 画布铺满定位父容器;随后覆盖 React Flow 背景、选中边框和 attribution 外观。 */
.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
.editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; }
.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
/* 手形模式在 pane/节点上给 grab 光标,拖动 pane 时改为 grabbing。 */
.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
/* 卡片基础边框/圆角/阴影,selectedNode 单独标记选择;标题绝对放在节点上方。 */
.node { position: relative; padding: 0; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
/* 文本填满 flex 容器,允许尺寸缩到零且关闭原生 resize;焦点时覆盖全局输入阴影,避免额外边框。 */
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
/* 卡片图片保持原比例装入可用空间,不以图片固有尺寸撑开节点。 */
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; }
/* 工具栏按 aria-pressed 高亮当前模式;disabled 光标不表示实际业务锁逻辑。 */
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.toolbar button:disabled { cursor: not-allowed; }
/* 右上工具条纵排;缩放值固定 48px 并使用等宽数字,避免百分比变化挤动旁边按钮。 */
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
.toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; }
.toolbar .zoomValue:hover { background: #e9e7e3; }
/* 底部主工具栏相对画布居中;帮助、错误等浮层各自定位和设层级。 */
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
/* 帮助面板固定在视口右下方,有宽高上限和内部滚动,长内容不溢出视口。 */
.helpPopup { position: fixed; inset: auto 12px 62px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
.helpPopup strong { color: #49372e; font-size: 13px; }
.helpPopup p { margin-top: 9px; }
/* 画布错误在上方居中显示,宽度随视口收缩,不参与节点几何计算。 */
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
/* 迁移控制面板处于较高层级;按钮状态、是否允许迁移由组件判断。 */
.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
.migrationPanel button:disabled { cursor: default; opacity: .5; }
/* 旧/迁移画布表面铺满宿主,具体渲染内容由桥接组件决定。 */
.migrationSurface { position: absolute; inset: 0; }
/* 窄屏把右上工具条下移避让标题,底部工具条改左对齐并横向滚动,预留右侧帮助空间。 */
@media (max-width: 700px) {
.topRight { top: 62px; right: 12px; }
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
}
/* 手形模式让文本/图片不拦截指针;右上工具条自身横向滚动以容纳窄视口。 */
.handMode .node textarea, .handMode .node img { pointer-events: none; }
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
/* 隐藏旧 handle 且禁用命中,新的中心点交互在后续规则控制。 */
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
/* 变换容器以中心为 transform-origin,保留溢出给标题、handle 和提示;边框/阴影变化有短过渡。 */
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; }
/* 纯内容 frame 默认透明边框,选中才显示;plainText 后置 padding 覆盖卡片文本的 14px。 */
.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
.plainFrame.selectedFrame { border-color: #a47764; }
.plainText { padding: 4px; }
/* 中心连接点默认透明且不响应指针;选中只改变可见性,connectionindicator 才开放命中。 */
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; }
.selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; }
.node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
/* 右下缩放按钮单独命中、禁止触摸默认行为;旋转模式隐藏它,避免两种手势同时起动。 */
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; }
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
/* 旋转时由 frame 接收手势,文本和中心连接点让出指针。 */
.rotatingFrame { cursor: grabbing; touch-action: none; }
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
/* 角度提示放在 frame 下方,禁止命中以免遮住正在进行的手势。 */
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
/* 节点样式工具栏采用横排可滚动表面,按钮尺寸固定;数值/选择器共用高度。 */
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
.styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; }
.styleToolbar input[type="number"] { width: 48px; }
/* 可见圆点只显示颜色,透明原生 color input 覆盖整个点击区域。 */
.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; }
.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; }
.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
/* 分支菜单固定定位并高于普通工具条,菜单项整行点击;定位坐标由组件计算。 */
.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; }
.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; }
.branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; }
.branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; }
.branchMenu button:hover { background: #f1e6df; }
/* 节点图标填满内容区域,SVG 自身不截获指针,事件交给外层节点。 */
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
.iconContent svg { pointer-events: none; }
/* 图标选择器固定在左侧,可用高度限定并采用纵向 flex,头/搜索/分类与列表分区。 */
.iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; }
.iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; }
.iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; }
.iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
/* 普通图标网格五列并内部滚动;后续 virtualIconGrid 是虚拟列表视口,窗口项目由 JS 定位。 */
.iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; }
.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
/* 来源选择器限制宽度,分类按钮可换行并按 aria-pressed 显示当前筛选。 */
.iconSource { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 11px; }
.iconSource select { min-width: 0; max-width: 175px; padding: 4px; border: 1px solid #ded7ce; border-radius: 5px; background: transparent; }
.iconCategories { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.iconCategories button { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
/* flex:1 让列表吃剩余高度,overscroll contain 减少滚动传到外层;CSS 不负责裁剪数据窗口。 */
.virtualIconGrid { flex: 1; min-height: 96px; overflow-y: auto; overscroll-behavior: contain; }
.virtualIconGrid button { display: grid; place-items: center; border: 3px solid transparent; border-radius: 8px; background-clip: padding-box; color: #6d6054; }
.virtualIconGrid button:hover { background-color: #f1e6df; }
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; }
/* 图标资源加载提示与选择器使用相近层级/起点。 */
.iconLoading { position: absolute; left: 12px; top: 70px; z-index: 60; padding: 14px; border-radius: 10px; background: #fffefb; font-size: 12px; }
/* 归属徽标放在节点下方,限制长名称且不参与命中;旋转时隐藏以减少遮挡。 */
.membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
.rotatingFrame .membershipBadge { display: none; }
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
/* 名称模式按钮宽度随文字,菜单固定定位且限制在视口内,勾选列保持对齐。 */
.styleToolbar .frameModeTrigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: auto; min-width: 96px; flex: 0 0 auto; padding: 0 7px; font-size: 12px; }
.frameModeMenu { position: fixed; z-index: 120; width: 220px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 5px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 8px 28px #32251a1f; }
.frameModeMenu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
@@ -94,33 +127,43 @@
.frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; }
/* 加入目标用绿色边框/光晕,移出预览改虚线;提示元素本身不拦截拖放。 */
.frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
.frameNode.frameDropExit { border-style: dashed; }
.frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; }
/* 归属变更通知居中放在底部工具栏上方;空内容隐藏,非空也不接管指针。 */
.membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; }
.membershipNotice:empty { display: none; }
/* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */
.handMode .plainText { pointer-events: none; }
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
.handMode .styleToolbar { display: none; }
/* 第一组减少动态效果规则关闭 frame、角缩放和中心点的过渡;后面还有分段滑块/连线动画的关闭规则。 */
@media (prefers-reduced-motion: reduce) {
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
}
/* 分区主体 pointer-events:none,让内部节点/画布继续接收事件;标签和四边命中条另行启用 pointer-events。 */
.frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
/* 名称放在分区顶部外侧并以左下角缩放;文字截断但标签自身可拖动。 */
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
/* 四条 10px 命中带跨在视觉边框两侧,扩大拖动区域,不给整个分区内部铺点击层。 */
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; }
.frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; }
/* 分区改名输入沿用工具栏高度;手形模式下标签和命中边框也不接管事件。 */
.styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; }
.handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; }
/* 同步错误提示在顶部居中并允许按钮换行,说明文字独占一行。 */
.syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; }
.syncError span { flex-basis: 100%; }
/* 恢复对话框限制视口宽度并居中,backdrop 给遮罩色;模态焦点行为来自 dialog 而不是 CSS。 */
.recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; }
.recoveryDialog::backdrop { background: #30251e33; }
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
@@ -128,24 +171,32 @@
.recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
/* 文本编辑容器允许 flex 收缩;readonly textarea 让出指针,由上层选择层处理选择/拖动。 */
.textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; }
.textEditor textarea[readonly] { pointer-events: none; }
/* 透明选择层盖住内容区域,选中后给 grab 光标;手形模式取消该层命中。 */
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
.handMode .textSelectLayer { pointer-events: none; }
.selectedFrame .textSelectLayer { cursor: grab; }
.textSelectLayer:active { cursor: grabbing; }
/* 纯图片没有卡片 padding,图片仅显示不接受选择/拖动,外层控制变换。 */
.plainImageFrame { padding: 0; }
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
/* 标题省略与改名输入分开;输入固定目标宽度并继承字体。 */
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
/* 三段图标选项用 isolation 建局部层叠,按钮在滑块上方,背景由滑块提供。 */
.frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; }
.frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; }
.frameLabelSegments button[aria-checked="true"] { color: #78523f; }
/* 滑块绝对定位并用 transform 做短过渡,实际位移由组件按选项设置。 */
.frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; }
/* 八向缩放 handle 按中心锚定,pointer-events:auto 覆盖外层让出命中的行为;手形模式再禁用。 */
.resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; }
.handMode .resizeHandle { pointer-events: none; }
/* 连线工具条可换行,数字、颜色、范围和选择器分别限宽,避免长表单撑出视口。 */
.edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; }
.edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
.edgeToolbar input[type="number"] { width: 50px; }
@@ -156,17 +207,24 @@
.edgeToolbar button:hover { background: #f1e6df; }
.edgeToolbar button:disabled { opacity: .4; }
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
/* 曲线控制点用 pointer-events:all 开放 SVG 命中,触摸默认行为关闭,拖动逻辑由事件处理器负责。 */
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
/* 连线效果分别用 dashoffset 流动和 opacity 脉冲;是否附加动画类由执行状态/配置决定。 */
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
/* 虚线一个周期移动 40 单位;脉冲从低透明到不透明再返回。 */
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
/* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
/* 全画布 busy 覆盖层是独立呈现态;成员局部同步使用后部 memberSync,不应从这个类推断所有任务都会锁画布。 */
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
/* 子图显示比例输入窄排布;折叠子图的摘要占内部 inset 区域并可点击。 */
.subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; }
.subgraphScale input { width: 45px; }
.collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; }
.collapsedSubgraph small { font-size: 10px; }
/* 元素 AI 面板限制视口宽度;标题信息省略,提示词可纵向伸展,底部模型/传播/执行按钮横排。 */
.aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; }
.aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
@@ -176,32 +234,40 @@
.aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; }
.aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
.aiPanel footer button:disabled { opacity: .35; }
/* 普通提示与 role=alert 错误采用不同颜色;冲突候选区域独立滚动并保留预格式文本换行。 */
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
.aiPanel p[role="alert"] { color: #a25439; }
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
.aiConflict pre { white-space: pre-wrap; }
.aiConflict img { max-height: 100px; }
.aiConflict button { padding: 6px; color: #78523f; }
/* 对通用变换 frame 再声明中心 handle:默认不可见不可命中,选中显示、connectionindicator 开启命中。 */
.transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; }
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
/* 端口先定义几何,再在下一条默认隐藏/禁用;选中只显示,是否可连仍由 connectionindicator 决定。 */
.framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
.framePort { opacity: 0; pointer-events: none; }
.selectedFrame .framePort { opacity: 1; }
.framePort:global(.connectionindicator) { pointer-events: auto; }
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
/* busy 状态中的恢复/重试按钮仍显示明确可点表面。 */
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
/* 图片上传状态浮在底部工具栏上方,宽度受容器限制,不作为节点持久数据。 */
.imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; }
/* 成员同步只显示紧凑状态和下划线操作按钮,不使用全画布遮罩。 */
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
/* 导入状态面板位于右上,有宽高上限和内部滚动;summary 切换详情,任务之间用边线分隔。 */
.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; }
.importPanel summary { cursor: pointer; padding: 5px 0; }
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
.importPanel p { margin: 5px 0; }
.importPanel small { color: #92877c; }
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
/* 导入动作横排,其余任务按钮使用文字色与局部留白,任务状态由 runtime/组件决定。 */
.importActions { display: flex; align-items: center; gap: 8px; }
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
+84
View File
@@ -1,3 +1,9 @@
/**
* 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。
* 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。
* 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。
* 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。
*/
"use client";
import { useImageUploads } from "./use-image-uploads";
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
@@ -41,15 +47,21 @@ import type { NodeTransform } from "@/lib/canvas-geometry";
import { MaterialNode, NodeEditing } from "./material-node";
import styles from "./canvas.module.css";
/** 旧边统一使用可编辑贝塞尔组件,节点按旧type名称分派;类型映射本身不持有元素实例数据。 */
const edgeTypes = { default: CanvasEdge };
/** 图标面板延迟在客户端加载,避免首屏加载目录面板及其交互代码;加载中提供可访问状态。 */
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
/** ReactFlow type到节点展示组件的固定表,须与makeCanvasNode/投影生成的type保持一致。 */
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
/** 每个挂载对应一个board身份,初始快照/草稿只在useState初始化时采用;切画布应由上层key重挂载。 */
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
const flow = useReactFlow<CanvasNode>();
const { zoom } = useViewport();
const reduceMotion = useReducedMotion();
const storageKey = canvasStorageKey(board.workspace_id, board.id);
// 服务端快照优先,另有不同本地内容则等待恢复选择;无快照可直接用草稿。
// 视口独立读本地键并限制范围,损坏视口不阻断文档;草稿解码失败回退快照/空文档并提示。
const [initial] = useState(() => {
try {
const local = readLocalRecovery(localStorage, storageKey, snapshot);
@@ -62,6 +74,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
});
// 工具与变换模式是界面状态,不放入文档;draggedIds仅驱动成员预览,membershipNotice短暂展示结果。
const [tool, setTool] = useState<"select" | "hand">("select");
const [error, setError] = useState(initial.error);
const feedback = useContext(FeedbackContext);
@@ -72,26 +85,35 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
const [rotating, setRotating] = useState<string | null>(null);
const [draggedIds, setDraggedIds] = useState<string[]>([]);
const [membershipNotice, setMembershipNotice] = useState("");
// 防节点拖动与多选拖动回调重复进入;起点按显示ID保存,连线屏幕起点用于区分点击与拉出新分支。
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
const dragOrigins = useRef(new Map<string, XYPosition>());
const draggingFrame = useRef(false);
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
// 图片分支先保留源节点/方向/位置,等文件选择返回;cancel事件要清它,不能沿用上次创建意图。
const pendingImage = useRef<BranchRequest | null>(null);
/** 稳定关闭回调供Portal菜单与画布事件共用,避免菜单因每次渲染重新安装监听。 */
const closeBranch = useCallback(() => setBranchRequest(null), []);
const imageKind = useRef<"image" | "plain-image">("image");
const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(null);
const [recovery, setRecovery] = useState(initial.recovery);
// 根恢复待决时暂停保存;tree负责真实owner文档,UI显示节点来自其树投影。子图恢复按Map首项逐个处理。
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
const childRecovery = [...tree.recoveries][0];
/** 下载单份内存文档JSON备份,稍后释放Blob URL;不修改草稿选择,也不保证包含所有子图。 */
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
const sync = tree;
// snapshot存在才执行真实AI;state.present组合根元数据与整树显示节点/边,不能把它当单board持久快照。
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
const treeRef = useRef(tree); treeRef.current = tree;
// 上传完成回调通过treeRef定位当前归属,并更新当前文档与历史;job.owner可能已不再是最新owner。
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
const imageUploading = demoImageUploading;
// 文件导入等待期间的迁移检查标记;这不是通用导入队列,也不提供并发文件事务锁。
const ioActive=useRef(false);
// 上传任务与文档都可能变化:按pendingUpload重新找owner,补资源或纯图片默认尺寸,避免移动/撤销后遗漏占位。
useEffect(() => {
const current = treeRef.current;
for (const job of uploads.jobs) {
@@ -102,38 +124,48 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
}
}, [uploads.jobs,tree.documents]);
/** undo/redo走异步树历史接口并显示失败,其余动作走根document reducer;不把服务端事务撤销当普通reducer动作。 */
function dispatch(action: CanvasAction) {
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
treeRef.current.dispatch(action);
}
// 浏览器文件选择取消不触发普通onChange,单独监听cancel清理待建图片分支;卸载拆监听。
useEffect(() => {
const input = imageInput.current;
/** 取消本次选择意图,不删除已经存在的图片节点或中止已启动上传。 */
const cancel = () => { pendingImage.current = null; };
input?.addEventListener("cancel", cancel);
return () => input?.removeEventListener("cancel", cancel);
}, []);
const { nodes: displayNodes, edges } = state.present;
// 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
/** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示ID。 */
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
// 预览URL按显示ID提供;正式图片onLoad再映射回job,上传完成的任务才回收本地URL。
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
// 从场景节点派生分区名称、已有frameId计数与本次拖入/拖出候选,只作显示,不在此改变owner。
const frameFeedback = useMemo(() => {
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
const counts = new Map<string, number>();
for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1);
return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
}, [nodes, draggedIds]);
// 成员提示四秒后清理;重新提示重置计时器,卸载取消。
useEffect(() => {
if (!membershipNotice) return;
const timer = setTimeout(() => setMembershipNotice(""), 4000);
return () => clearTimeout(timer);
}, [membershipNotice]);
// 全局反馈可用时把本地错误转成稳定ID通知并清本地error;无Provider时保留底部内联提示。
useEffect(() => {
if (!feedback || !error) return;
const id = `canvas:${board.id}:error`;
notify({ id, level: "error", title: error });
setError("");
}, [feedback, error, board.id]);
// 成员通知使用board级稳定ID,内容变化替换旧通知,清理时显式dismiss。
useEffect(() => {
if (!feedback || !membershipNotice) return;
const id = `canvas:${board.id}:membership`;
@@ -141,29 +173,40 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return () => dismissNotification(id);
}, [feedback, membershipNotice, board.id]);
const duration = reduceMotion ? 0 : 180;
/** 视口派发给根并独立写本地键;存储失败不打断浏览,内容writer自行判断是否需要服务端保存。 */
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
treeRef.current.dispatch({ type: "viewport", viewport });
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
};
/** 工具栏视口动画完成后保存实际视口;错误转界面提示,不提前记录尚未完成的目标值。 */
const changeView = (operation: () => Promise<boolean>) => {
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
};
// latest保留当前显示几何,拖动结束基于最新节点计算成员反馈,避免只使用开始时的闭包。
const latest = useRef(state.present);
latest.current = state.present;
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
const select = useCallback((id: string) => treeRef.current.select(id), []);
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
/** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
/** 打开分支类型菜单并退出变换模式,point用于屏幕定位。 */
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
/** 将场景位置减owner原点再除scale,宽高也除scale;旋转等其余字段直接交旧transform动作。 */
const transform = useCallback((id: string, transform: NodeTransform) => {
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
}, []);
/** 样式适配只构造旧style动作,具体字段合并由reducer处理。 */
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
/** 节点名称通过owner映射更新,避免将显示复合ID当持久ID。 */
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
/** 分区属性以replace提交;折叠切换从已保存expanded/collapsedSize或默认值恢复尺寸,并写node与style。 */
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
const document = current.documents.get(ref.boardId)!;
@@ -174,7 +217,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
}) } }, ref.boardId);
}, []);
// 注入节点动作及本地旋转/拉伸模式,两模式互斥;旧版接口没有新版手势token租约。
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
/** 在源owner文档创建节点与连线,at从场景坐标转局部;发布后尝试把视口移向新节点,异常留作提示。 */
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
try {
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
@@ -184,11 +229,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setBranchRequest(null);
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
}
/** 图片分支需要文件选择才能创建,先保存request并关菜单;文本/脚本立即创建。 */
function chooseBranch(kind: "text" | "image" | "script") {
if (!branchRequest) return;
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
else createBranch(branchRequest, kind);
}
/** 无有效目标的连接拖动若移动至少5px并落到空白pane,且源仍选中,则在落点打开分支菜单。 */
const connectionEnd: OnConnectEnd = (event, connection) => {
const start = connectionStart.current;
connectionStart.current = null;
@@ -201,11 +248,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
};
/** 按窗口中心转换为场景点并加六档偏移,减少连续添加完全重叠;不是按编辑器容器中心计算。 */
const center = () => {
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
const offset = (nodes.length % 6) * 18;
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
};
/** 根节点上限500;真实分区走subgraphs命令创建子画布,其它节点本地加入,演示frame也走本地模型。 */
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
const node = makeCanvasNode(kind, center(), src);
@@ -218,6 +267,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
setTool("select");
}
/** 一次replace插入三张预填卡片和两条连线,预留三个节点容量并切回选择工具;不触发AI生成。 */
function addTemplate() {
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
const position = center();
@@ -233,6 +283,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
] } });
setTool("select");
}
/** idle才建立checkpoint与拖动起点;有frame参与则不显示普通拖入预览,防止移动分区误收纳覆盖内容。 */
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
@@ -242,6 +293,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
setMembershipNotice("");
}
/** 只结束active拖动,先清拖动/保存暂停标记,再按真实位移计算归属;真实子图与旧frameId归属分路处理。
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "active") return;
const origins = new Map(dragOrigins.current);
@@ -252,19 +305,23 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
if (ids.length) {
const positions = new Map(items.map((item) => [item.id, item.position]));
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
// 先生成“加入/移出”展示文案;实际owner由下面的真实子图路径重新判定,成功后才显示结果通知。
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
if (snapshot && tree.rootDocument.schema_version === 2) {
try {
// 按source/target分组,同组节点一起迁移;候选分区按面积升序,优先最小包围分区。
const byOwner = new Map<string, { ids: string[]; target: string }>();
const parents = subgraphParents(tree.current.current);
for (const item of items.filter((item) => ids.includes(item.id))) {
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
/** 沿子图父链防环检查,不能把分区入口移动进自己的后代,形成结构循环。 */
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
// 未上传且满足条件的占位图片先走本地迁移;其它节点需要服务端成员事务。
if (target !== ref.boardId && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
tree.movePendingImage(ref.boardId,target,ref.id);continue;
}
@@ -272,6 +329,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
}
}
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
await Promise.all([...byOwner].map(async ([key, group]) => {
let source = key.split("/")[0];
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
@@ -287,10 +345,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
}
}
/** 先把入口端口解析成实际内部节点;同owner本地加边,跨owner优先服务端connections事务,缺能力时尝试暴露端口适配。 */
async function connect(connection: Connection) {
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
if (!source || !target) return;
try {
/** 按深度计数0—8解析子图入口,每层必须找到明确port映射;仍未到实际节点或缺端口则拒绝。 */
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
for (let depth = 0; depth <= 8; depth++) {
@@ -303,6 +363,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
throw new Error("subgraph_cycle");
};
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
// 同一canvas快照可直接加边;跨canvas需要共同上层/边界端口关系,不能把显示端点直接写进任一子快照。
if (realSource.boardId === realTarget.boardId) {
const document = tree.current.current.get(realSource.boardId)!;
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
@@ -316,11 +377,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return;
}
}
// 兼容路径由exposedConnection提供可寻址端点与owner,失败提示跨画布连接暂不可用。
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
const document = tree.current.current.get(exposed.boardId)!;
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
}
/** 校验MIME与大小并清文件input;迁移期间拒绝。真实画布通过上传占位,演示以Data URL直接加入。 */
async function readImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
@@ -331,6 +394,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
const request = pendingImage.current; pendingImage.current = null;
const kind = imageKind.current;
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
if (snapshot) {
try { const job = uploads.start(file, (src) => {
const current = treeRef.current;
@@ -343,12 +407,14 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
return {owner:board.id,node};
});
// pendingUpload以node.id标记,后续资源完成/跨区移动/历史恢复都按这个身份找到占位。
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
const node={...job.node,pendingUpload:job.node.id};
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
setTool("select"); } catch(error) {setError(errorText(error));}
return;
}
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
setImageUploading(true);
try {
const src = await new Promise<string>((resolve, reject) => {
@@ -370,6 +436,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
finally { setImageUploading(false); }
}
/** 旧版导入最多5MB并全量读取解码,替换当前根快照;任一方含真实子图时拒绝,避免破坏跨board关系。
* ioActive用于阻止迁移交接;这条路径不是新版渐进导入,也不会自动重建bundle子图。 */
async function importProject(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
@@ -387,6 +455,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
finally{ioActive.current=false;}
}
/** 有上传占位则先等待用户完成;树导出完整可达文档,多board写bundle,单board写当前视口。
* 下载名清理文件名非法字符,Blob URL延迟释放;不额外打包File/Blob,已有演示Data URL仍随src导出。 */
async function exportProject() {
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
@@ -399,7 +469,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。
useEffect(() => {
/** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */
const keydown = (event: KeyboardEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
@@ -417,6 +489,11 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return () => window.removeEventListener("keydown", keydown);
}, []);
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
return (
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
<ImagePreviews.Provider value={imagePreviews}>
@@ -427,6 +504,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onBeforeDelete={async ({ nodes: removing }) => {
if (tree.busy || tree.transactionBusy) return false;
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
const linked = removing.filter((node) => node.data.subgraph);
const parents = subgraphParents(tree.current.current);
const referenced = removing.filter((node) => {
@@ -436,6 +514,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
});
if (!linked.length && !referenced.length) return true;
try {
// 分区逐个事务解散,参与者包含父、子、孙及需要调整引用的祖先;并非一批选择全体原子操作。
for (const node of linked) {
const ref = tree.views.nodeRefs.get(node.id)!;
await tree.ensure(node.data.subgraph!.boardId);
@@ -445,6 +524,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
}
// 解散后owner可能变化,按原ID重新查唯一归属;找不到唯一目标就跳过,不误删同ID的其它节点。
for (const node of removing.filter((node) => !node.data.subgraph)) {
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
let owner = ref.boardId;
@@ -461,6 +541,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
// 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。
return false;
}}
onNodesChange={tree.nodesChanged}
@@ -486,6 +567,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
// AIPanel收到owner原始节点,显示ID仅用于NodeToolbar定位;save仍经tree.nodeAction回写owner。
const ref = tree.views.nodeRefs.get(node.id)!;
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
@@ -558,6 +640,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
</div>}
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
// 成员任务提示跟随当前source/target中的场景节点,队首同步、后续等待,失败通过retryMembers继续。
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
@@ -577,6 +660,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
);
}
/** 为单个旧编辑器建立独立ReactFlow store;board/snapshot身份切换的重挂载由路由调用方负责。 */
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
}
@@ -1,3 +1,9 @@
/**
* 新版主画布的React/ReactFlow装配。engine拥有文档、历史和运行事实,sessions拥有每视图选择/工具/手势;组件只保存显示缓存与面板状态。
* 文档提交先推进场景索引,再按事件类型立即或逐帧发布;几何预览通过session进入投影,提交才产生文档修改。
* 画布适配层负责DOM测量、命中、裁剪、焦点和资源显示,core不读取DOM;渲染组件通过动作上下文调用editor/runtime。
* 外层只借用项目engine:挂载attach视图,卸载detach,不关闭工作区持有的项目或持久任务。
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
@@ -40,9 +46,13 @@ import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
import { ImagePreviews } from "./canvas-image";
import styles from "./canvas.module.css";
/** 把适配层样式等可序列化值规范化成JSON Data,去掉undefined等JSON不保留的属性;不处理循环或任意非JSON对象。 */
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
/** 已attach视图的内容组件;需要外层ReactFlowProvider与CanvasProvider。root/session是发布快照,current供回调读取最新投影。 */
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
// 场景索引按engine+view复用,提交订阅先advance变更,再通知显示,避免每次渲染重建整图索引。
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
/** 用户字段修改/替换删除及undo/redo立即发布,保证受控输入及时回显;批量创建和后台修改允许合并到下一帧。 */
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
let before=engine.getSnapshot().document;
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
@@ -52,16 +62,20 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
notify(immediate);
});
}, [engine,sceneCache]);
// rootFrames只调度呈现通知,不延迟editor真实提交;sessionFrames独立合并手势/选择等视图变化。
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
// 视图被detach后保留最近view供本轮Hook计算,最终返回“画布已关闭”;不以lastView恢复已关闭会话。
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
if (liveView) lastView.current = liveView;
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
// 完整加载后允许普通创建;加载中要求loading版本支持且当前canvas覆盖完整,最终命令层仍再校验准入。
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
// 加载预览首次适配只取当前canvas直接节点,避免展开后代把视口拉得过远;有交接视口时不再自动fit。
useEffect(()=>{
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
// Expanded descendants can extend far beyond their partition entry. Initial
@@ -70,7 +84,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
if(!nodes.length)return;
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
},[engine,flow,root.document,view.canvasId]);
// 复制准备任务来自runtime状态;copying防本地重复点击,两者共同禁用复制入口。
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
// 焦点与epoch用于保留活动输入和排除迟到blur;切engine/view复位,移除renderer未触发blur时另检查isConnected。
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
// Removing an active renderer need not dispatch a native blur. Retire only
@@ -78,19 +94,26 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
const [copying, setCopying] = useState(false);
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
// 选择转成id/incarnation后查询深复制可用性;渲染时的可用性仅用于UI,点击时再次捕获实际选择交runtime。
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
const [error, setError] = useState("");
// resource显示状态带engine/view身份,旧显示器迟到回调不能为当前画布提供错误图片;sources不是文档资源表。
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
// 拖动保存起始场景坐标、指针ID与会话token,positions只含顶层选中节点,防分区与成员重复位移。
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
// 复用投影对象与测量结果,避免ReactFlow把纯元数据变化误当节点卸载,影响输入及连接点。
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
projectionCache.current ??= createProjectionCache();
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
// groups按元素生命周期保存一次文本编辑的history分组ID,元素重建后清旧分组,不能跨incarnation合并编辑。
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
// 加载期使用受限renderer catalog,完整后回到正常定义;sceneCache把文档、会话预览与查询组成显示场景。
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
// 仅在尺寸一致且投影缓存确认身份/变换允许时复用measured;离开场景的节点清测量条目。
const projection = useMemo(() => {
const result = scene;
// Preserve renderer measurements across metadata/preview updates. Dropping them
@@ -100,36 +123,50 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
}, [engine, root.document, scene,view.canvasId]);
// 每个view独立OverlayIndex,Portal控件通过索引贡献边界与焦点;不会把浮层几何存入项目文档。
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
const surfaceBounds=surface.current?.getBoundingClientRect();
// 绘制范围索引用于可见性筛选;sceneViewport把ReactFlow平移/缩放反算为场景矩形。
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
// 过滤元素生命周期已变的浮层;缺surface矩形时只按身份保守保留,不凭缺失测量断言不可见。
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
// 未知绘制范围必须保留,加上矩形查询命中;不能把unknown当成屏幕外。
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
const nativeNodes=useStore(state=>state.nodes);
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
// 连线范围依赖真实Handle测量:新版/未知连线使用measuredConnector,兼容节点使用旧几何适配。
// 订阅nativeNodes让测量更新后重算连线路径范围,不把DOM测量要求下放core。
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
}, []);
/** 发命令前读取当前incarnation,元素缺失即抛错;渲染侧更早的身份检查由useScopedNodeEditing等负责。 */
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
/** 统一元素data补丁入口,可携history groupId;schema、能力和事务合法性留editor验证。 */
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
/** 把ReactFlow选择变化合到session当前Set,成员完全相同则不重复发布;不把selected写入文档。 */
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
const current = engine.sessions.getSnapshot().views.get(viewId)!;
const ids = new Set(current.selection);
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
}
/** 空白点击补充core投影命中:12px容差、预览目标及实际Handle/layer适配;只选择confirmed命中。
* 命中计算后再次比较document/session快照身份,过期帧不能选中新incarnation;抓手模式点击清选择。 */
function selectAtPane(event: {clientX:number;clientY:number}) {
const captured=current.current;
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
@@ -149,12 +186,16 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
const hit=result.hits[0];
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
}
/** session.begin成功后捕获gesture token,以guardEditingLease约束后续preview/commit/cancel只作用于该交互。 */
function beginTransform(id: Id) {
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().gesture!.token;
/** 每次租约动作实时核对token,旧pointerup不能结束后来新建的手势。 */
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
}
/** 将显示空间位置/尺寸转成owner局部Geometry:尺寸除scale,以中心toLocal后再求左上角,旋转减去父旋转。
* 这里只更新session预览,不直接dispatch文档;后续commit统一形成history与同步修改。 */
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
const { root, projection } = current.current, element = root.document.elements.get(id);
if (element?.structure !== 'node') return;
@@ -165,16 +206,21 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
report(engine.sessions.update(viewId, target(id), patch));
}
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
* 默认位置从首个data-headless-canvas的DOM矩形取屏幕中心再转场景坐标,at可显式覆盖。 */
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
// 各类型使用固定默认尺寸及初始incarnation/dataVersion;资源引用由已stage/adopt的ref注入,不在这里上传。
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
/** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
return id;
}
/** 选择工具才可建边;拒绝unsupported端点,将owner设为两端所属canvas的共同上级,端口ID来自连接事件。 */
function connect(connection: Connection) {
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
@@ -182,21 +228,30 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
}
// 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
// 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。
useEffect(() => {
if (!liveView) return;
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
resourceView.current=view;
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
}, [engine,viewId,Boolean(liveView)]);
// 资源目标集合变化才sync,映射失败在本面板报错,不擅自修改文档中的assetId。
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
// 再按engine/view及当前assetId过滤结果,节点切换资源时不显示上一个资源的迟到URL。
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
/** 一次事务删除当前选择中仍存在的元素;关联结构处理由editor命令实现,不循环修改投影数组。 */
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
// 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
// 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。
useEffect(() => {
/** 核对viewId后取消session并清本地拖动记录;window blur不要求指针事件。 */
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
// Bubble after the target/React handlers: an input owner can claim Escape first.
/** 冒泡阶段让输入控件优先认领Escape,再按统一路由决定是否取消手势。 */
const escape = (event: KeyboardEvent) => {
if(event.key!=='Escape')return;
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
@@ -208,10 +263,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
const modes = view.modes;
// Retain explicit owners in the native renderer, leaving the full graph in its
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
// 保留模式目标、可见绘制对象、浮层目标和本视图手势;完整图仍留ReactFlow查询表,不以伪造拖动避裁剪。
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
for(const entry of visibleOverlays)retained.add(entry.target.id);
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
// 活动输入可来自节点、连线、Portal浮层或独立UI;无法证明其归属时保守关闭可见性裁剪。
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
@@ -221,10 +278,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
if(focusedId)retained.add(focusedId);
// Retained connections also keep their measured endpoint owners alive.
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
// 所有保留对象仍须存在于文档,且视口尺寸已知,才启用native onlyRenderVisibleElements。
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
// 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
@@ -233,15 +292,19 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
// Actions use current.current / engine snapshots instead of captured document projections.
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
// 新版资源URL寿命由resourceView管理,CanvasImage的loaded回调这里无需删除旧版上传任务。
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
// 给旧节点renderer注入新版动作:begin/end维护文本history分组与editing mode,正文按同incarnation使用groupId。
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
// 分支直接在当前canvas按方向偏移创建文本,再同一事务创建连线;与旧版弹类型菜单的行为不同。
branch: (id, direction) => {
const node = projectionCache.current!.getNode(id); if (!node) return;
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
@@ -249,19 +312,23 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
},
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
// 曲线编辑同样捕获connectionGesture token;active暂为空集合,连线renderer在此不从run状态获取执行动画。
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
},
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
// 临时path只更新session;正式修改把data和path分开命令,在同一个事务提交,避免把路径写进业务data。
patch: (id, value, transient) => {
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
const path = transformPath((value.path ?? null) as Json, t, true);
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
// 工具栏可能传回整个显示data;只有显式含path且与显示路径不同才提交connection.path,避免无意重写。
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
const { path: ignored, ...rest } = value; void ignored;
@@ -269,8 +336,15 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
},
}),[engine,viewId,report]);
// 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
// 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。
if (!liveView) return <p role="status">画布已关闭</p>;
// surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。
// 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。
// ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。
// 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。
// 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
@@ -296,6 +370,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
onNodeDragStart={(event, node, nodes) => {
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
// 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
const point = 'clientX' in event ? event : event.changedTouches[0];
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
@@ -305,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
onNodeDragStop={event => {
// 仅结束匹配本视图和token的拖动,迟到dragStop不提交当前其它手势。
const saved = drag.current; drag.current = null; pointerStart.current = null;
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
@@ -318,6 +394,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
// 深复制点击时捕获选择身份,异步完成只更新同engine/view的面板;runtime负责进度与已加入内容。
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
setCopying(true); setError("");
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
@@ -339,9 +416,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
</div>;
}
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
const [handoffViewport]=useState(initialViewport);
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
}
+106
View File
@@ -1,3 +1,9 @@
/**
* 旧版画布树的状态与操作协调Hook。documents按boardId保存已加载快照,views把嵌套子画布投影为当前ReactFlow场景。
* 每个board有独立CanvasSync writer,本地历史和服务端结构事务共用两个栈,成员迁移另用串行内存队列。
* 该实现保留旧快照协议与本地草稿恢复,不等同于新版engine的统一提交根、持久ledger和恢复状态机。
* 调用方应按根/工作区身份重挂载;refs提供异步操作读取的最新状态,React state负责发布界面变化。
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EdgeChange, NodeChange } from "@xyflow/react";
@@ -8,20 +14,34 @@ import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
/** 命令返回的快照额外携带board_id,用于把多画布结果安装回各自writer。 */
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
/** 兼容多个旧命令端点的结果包络,可能返回父/子/多快照、事务身份、归档列表和版本;各使用路径再解码快照。 */
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
/** 本地历史保存操作前整份画布,可附关联画布;pendingMove标记尚未上传图片的跨画布移动,便于后来按服务端归属撤销。 */
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
/** 服务端结构事务历史保存可重放的路径/负载、事务ID和参与画布;撤销请求服务端undo,重做重新执行原命令。 */
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
/** 两种历史条目共用undo/redo栈,通过是否有id字段区分;不是每次pointermove都直接创建条目。 */
type Entry = LocalHistory | TransactionHistory;
/** 成员归属迁移的FIFO任务;run在准备后捕获固定body/key供不确定失败重试,refresh表示确定冲突后需刷新基线。 */
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
/** 只为一次reducer运算建立临时空历史,借其past是否变化判断该动作是否应进入本Hook自己的历史栈。 */
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
/** initial建立根画布,snapshot决定是否连接服务端writer;paused阻止保存/独占事务等准入,migration额外限制旧写入。
* parentBoardId用于补读祖先快照;导出、跨分区移动和事务可能按需加载更多画布。 */
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
// 有迁移上下文时先关旧编辑入口,等迁移协调组件取得共享租约再开放;flush只有迁移准备阶段临时启用。
const migrationGate=useRef(!!migration);
const migrationFlush=useRef(false);
const [migrationHeld,setMigrationState]=useState(!!migration);
/** 同步检查门闩和账号身份,事件处理无需等待React的migrationHeld重新渲染。 */
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
@@ -29,33 +49,42 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const value=await requestAPI<T>(path,{...options,headers:Object.fromEntries(headers)});
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
};
// documents是已加载board的模型表,current立即跟随发布供异步回调读取;status/error是旧writer汇总显示值。
const [documents, setDocuments] = useState(new Map([[root, initial]]));
const current = useRef(documents); current.current = documents;
const [status, setStatus] = useState("saved");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
// capability缓存查询Promise;members是实际内存队列,memberJobs只是供React显示的数组副本。
const memberCapability = useRef<Promise<boolean> | null>(null);
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
/** 仅挂载期间发布队列副本;任务对象仍共享引用,修改error等字段后需显式通知。 */
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
/** 拖动期间延后自动保存,结束时发布新Map触发防抖重新评估;不在这里执行成员归属变化。 */
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
/** 成员任务涉及的节点暂不能再被普通动作改动;队列非空时也保护子图入口几何及结构,避免请求基准漂移。 */
function protectedNodes(boardId: string) {
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
return ids;
}
const [pendingCommand, setPendingCommand] = useState(false);
// 不确定命令失败时保留同一execute闭包,pendingCommand驱动UI;locked只管当前独占异步操作。
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
// writer持每画布ACK基线与保存队列,loading去重子画布读取;二者都不是documents的替代存储。
const writers = useRef(new Map<string, CanvasSync>());
const loading = useRef(new Set<string>());
const [retryLoad, setRetryLoad] = useState(0);
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
/** 以当前服务端快照筛选有内容差异的本地恢复候选,按board记录,等待用户决定;不证明可安全覆盖模型。 */
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
}
/** 可编辑时按选择替换本地文档或采用现状,移除恢复候选和草稿记录;远端写入仍走后续writer。 */
function resolveRecovery(boardId: string, restore: boolean) {
if(migrationBlocked())return;
const draft = pendingRecovery.current.get(boardId);
@@ -63,12 +92,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
}
// 两个ref数组作为历史栈,boardAliases记录重做子图创建后新旧boardID对应;tick用于仅栈变化时通知UI。
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
const boardAliases = useRef(new Map<string, string>());
const [, tick] = useState(0);
// 根据根与documents派生投影索引,nodeRefs/edgeRefs把场景ID映射回所属board、本地ID和坐标变换。
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
const view = useRef(views); view.current = views;
/** 先更新同步ref,再在挂载期间发布React state;等待中的操作可立即读取新Map。 */
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
/** 将服务端快照作为writer基线:已有writer采用新ACK,否则在真实快照模式建立writer;这里只安装,不发保存。 */
function install(boardId: string, value: CanvasSnapshot) {
const writer = writers.current.get(boardId);
if (writer) writer.adopt(value);
@@ -80,25 +113,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Initialization never writes a snapshot to the server.
if (snapshot && !writers.current.has(root)) install(root, snapshot);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
/** 普通单画布动作入口:检查门闩,经reducer计算新文档,保留成员迁移保护字段,再按需要记历史并发布。 */
const update = (boardId: string, action: CanvasAction) => {
if (migrationBlocked() || locked.current || retryOperation.current) return;
const before = current.current.get(boardId); if (!before) return;
const result = canvasReducer(history(before), action);
const protectedIDs = protectedNodes(boardId);
// 迁移中的普通节点只允许选中变化;未作为移动目标的子图入口保留原几何、subgraph/ports/presentation。
// reducer删掉的受保护节点也补回,避免异步成员请求到来时对象已被本地删除。
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
const prior = before.nodes.find((item) => item.id === node.id);
if (!prior || !protectedIDs.has(node.id)) return node;
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
// reducer产生历史才入undo,普通更新路径最多留40项并清redo;其它事务/刷新路径不一定使用同一截断逻辑。
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
publish(new Map(current.current).set(boardId, result.present));
};
/** 默认对根发动作,也允许调用方显式指定已加载board。 */
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
/** 从投影节点ID解析实际owner,再用原始节点ID构造动作,避免把场景复合ID写回快照。 */
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
/** 按owner分组ReactFlow节点变化;忽略add/replace,将场景位置和尺寸还原为所属board局部坐标。 */
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
if (migrationBlocked() || locked.current || retryOperation.current) return;
const groups = new Map<string, NodeChange<CanvasNode>[]>();
// 同批父分区也在移动时先计算调整后的原点,避免对子节点重复施加父位移;尺寸再除投影scale。
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
for (const change of changes) {
if (change.type === "add" || change.type === "replace") continue;
@@ -106,6 +147,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
const next = { ...change, id: ref.id };
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
// ResizeObserver只更新测量信息;非显式resizing不得把测量宽高当模型尺寸写回。
if (next.type === "dimensions") {
// ResizeObserver measurements are editor state. Only an explicit resize
// gesture may write node dimensions back to the owning snapshot.
@@ -116,6 +158,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
};
/** 按edgeRefs分组选择/删除等变化,忽略add/replace;最终update仍统一检查写入准入。 */
const edgesChanged = (changes: EdgeChange[]) => {
const groups = new Map<string, EdgeChange[]>();
for (const change of changes) {
@@ -124,13 +167,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
};
/** 曲线路径从场景坐标反变换回owner局部空间,再派发edge-data;transient交reducer决定历史语义。 */
const patchEdge = (id: string, data: EdgeData, transient = false) => {
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
};
/** 遍历已加载文档,只有目标owner/localID被选中,其余节点取消选择;不直接操作投影数组。 */
const select = (id: string) => {
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
};
/** 读取并解码一个board的旧v2快照,可传取消信号;api层仍检查迁移账号身份。 */
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
/** 查询服务端是否支持成员迁移同时提交坐标;演示为false,成功结果复用,失败清缓存供按需重试。 */
function supportsMemberPositions(): Promise<boolean> {
if (!snapshot) return Promise.resolve(false);
if (!memberCapability.current) {
@@ -141,9 +188,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
return memberCapability.current;
}
// 能力预取失败不视作确定不支持,真正成员操作仍可重新请求。
useEffect(() => {
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
}, [root, workspace, !!snapshot]);
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
let document = current.current.get(boardId);
@@ -153,14 +202,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
}
/** 独占期间完整加载根下可达子图,然后收集导出Map;不会把已加载但不可达的祖先或其它分支混入导出。 */
async function exportDocuments() {
return exclusive(async () => {
await ensure(root, true);
const reachable = new Map<string, CanvasDocument>();
/** 可达集合兼作去重;ensure已验证递归关系,此处只遍历当前已加载文档。 */
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
collect(root); return reachable;
});
}
// 打开子画布时向上读取元数据与快照,补齐祖先关系;alive阻止离页后的采用,不在本effect中abort请求。
useEffect(() => {
if (!snapshot || !parentBoardId) return;
let alive = true;
@@ -178,6 +230,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Root identity is stable until this editor unmounts.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentBoardId, root, workspace]);
// 场景可见且未折叠的子图按需异步读取,loading去重;回包时重新确认仍被当前文档引用。
useEffect(() => {
for (const [boardId, document] of documents) for (const node of document.nodes) {
if (!view.current.boards.has(boardId)) continue;
@@ -192,6 +245,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Values and writers are scoped to this mounted root/workspace.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documents, retryLoad]);
// 文档变化防抖:250ms本地备份、800ms远端保存。恢复待决画布跳过,防止覆盖用户尚未选择的草稿。
// 远端另等独占锁、命令重试、成员任务和拖动结束;本地备份不需要等这些普通操作全部结束。
useEffect(() => {
if (paused || migrationBlocked()) return;
const backup = setTimeout(() => {
@@ -208,7 +263,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}, 800);
return () => { clearTimeout(backup); clearTimeout(save); };
}, [documents, paused, workspace, memberJobs, migrationHeld]);
// pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。
useEffect(() => {
/** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */
const backup = () => {
if (pause.current || migrationBlocked()) return;
for (const [boardId, document] of current.current) {
@@ -219,6 +276,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
window.addEventListener("pagehide", backup);
return () => { window.removeEventListener("pagehide", backup); backup(); };
}, [workspace]);
/** 串行排空各writer,任一恢复待决或writer错误就失败;deferMemberPositions保留待移动节点原ACK坐标。
* 支持原子坐标的成员接口会在归属事务里提交新位置,避免先把新位置单独写入旧owner。 */
async function flush(deferMemberPositions = false) {
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
@@ -228,6 +287,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
}
}
/** 迁移前必须已关门,无事务/成员/拖动/加载,且恢复已处理;加载整棵迁移树并核查上传占位。
* 先备份本页,再临时放行PATCH排空旧writer;所有writer确认无待保存内容后把备份标为clean。 */
async function prepareMigration(projectRoot:string){
if(!migrationGate.current)throw new Error('migration_gate_required');
if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
@@ -240,12 +301,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for(const[boardId,document]of current.current){
const writer=writers.current.get(boardId);
const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
// 本地另有不同恢复内容时转为用户选择,不能用当前内存直接覆盖它后迁移。
if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
}
// Preserve the current view even if it closes while PATCH is in flight.
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
}
// 只在flush的try/finally窗口放开旧PATCH,其它写命令仍被api护栏拒绝。
migrationFlush.current=true;
try{await flush();}finally{migrationFlush.current=false;}
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
@@ -255,15 +318,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
}
}
/** 旧版内存独占执行器,阻断冲突准入并发布busy;异常记error后继续抛出,finally可靠释放锁。
* 它不是跨标签页锁,也不提供操作的整批服务端回滚。 */
async function exclusive<T>(operation: () => Promise<T>) {
if (migrationBlocked() || locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
locked.current = true; setBusy(true);
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
finally { locked.current = false; if (mounted.current) setBusy(false); }
}
/** 服务端快照更新时恢复本地选中标记,避免结构事务结果抹掉当前视图选择。 */
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
}
/** 汇总命令返回的各类快照,按boardID去重,基于旧ACK做三方合并再安装新基线。 */
function accept(result: CanvasCommandResult) {
const next = new Map(current.current);
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
@@ -272,6 +339,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
}
// 子图归档后,将仍在内存上传的节点及相关边投到父画布,避免服务端快照中没有的占位被丢弃。
// 其余已归档文档及writer从当前表移除,不在这里删除上传任务的原文件。
for (const id of result.archived_board_ids || []) {
const local=current.current.get(id);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
@@ -286,6 +355,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
publish(next);
return [...new Set(values.map((value) => value.board_id))];
}
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
* execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
if (retryOperation.current) throw new Error("canvas_busy");
return exclusive(async () => {
@@ -295,21 +366,25 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await flush();
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
const execute = async (): Promise<CanvasCommandResult> => {
try {
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
const changed = accept(result);
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
if (!changed.length && result.canvas_version) {
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
}
// record=false用于undo/redo等已有历史流程,避免将历史遍历再次当新操作压栈。
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
retryOperation.current = null; setPendingCommand(false); setError("");
return result;
} catch (error) {
const code = error instanceof Error ? error.message : "request_failed";
// 网络/上游/解码等不确定失败保留execute并阻断普通编辑;确定拒绝清重试入口,错误仍交调用方处理。
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
else { retryOperation.current = null; setPendingCommand(false); }
throw error;
@@ -320,12 +395,15 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
// Ownership changes pause autosaves, not unrelated local editing. Server
// acknowledgements merge against the persisted base, preserving intervening edits.
/** 成员队列只跑队首;归属移动期间暂停autosave,允许不相关本地编辑,结果按捕获ACK合并。
* 只有成功才shift并resolve;失败任务留队首供重试,Promise不在失败时假装完成。 */
async function runMembers() {
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
try {
if (!job.run) {
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
// 412等确定冲突后的重新准备:读参与快照,先三方合并本地变化,再更新各writer基线。
if (job.refresh) {
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
const next = new Map(current.current);
@@ -336,6 +414,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [id, remote] of refreshed) install(id, remote);
publish(next); job.refresh = false;
}
// 支持坐标时flush延后待移动节点位置;否则先按旧保存行为排空,再捕获参与文档作为合并基线。
const atomic = await supportsMemberPositions();
await flush(atomic === true);
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
@@ -344,17 +423,22 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
return [id, node.position];
}));
// 请求内容含参与版本和可选局部坐标;创建job.run后重试不得换body或幂等key,除非确定原请求未应用。
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
const key = crypto.randomUUID(), historyIndex = undo.current.length;
/** 一次准备生成的可重试闭包;要求回执覆盖所有参与画布,不能凭缺失快照移动本地owner。 */
job.run = async () => {
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
/** 移动结果已含新坐标,合并前只把源节点暂存位置还原到基线,避免重复施加坐标变化。 */
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
/** 参与画布基于原ACK+归一化本地内容+返回快照重放,未参与文档原样保留。 */
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
// 请求期间新增本地历史也基于新owner结果调整;开始前历史和服务端事务条目保持原语义。
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
const next = new Map(current.current);
for (const [id, value] of values) {
@@ -369,6 +453,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [id, value] of values) install(id, value);
publish(next);
undo.current = rebasedHistory;
// 将迁移事务插回开始时的historyIndex,保留请求期间后来编辑的顺序,并清除旧redo。
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
};
}
@@ -378,6 +463,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
job.error = error instanceof Error ? error.message : "request_failed";
// A definite rejection performed zero writes. A retry may prepare fresh
// versions; ambiguous transport failures must retain the exact body/key.
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
showMembers();
} finally {
@@ -385,24 +471,31 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (members.current.length && !members.current[0].error) void runMembers();
}
}
/** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
return new Promise<void>((resolve) => {
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
});
}
/** 将上传资源/尺寸绑定到所有当前文档及本地历史,避免撤销恢复旧Blob占位。
* 无asset时保留pendingUpload;尺寸仅在纯图片仍为320×240默认值时采用,不覆盖用户已调整大小。 */
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
if(migrationBlocked())return;
/** 只匹配pendingUpload身份,移除或保留占位标记,并按文档schema选择是否写assetId。 */
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
if (node.pendingUpload !== id) return node;
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
const {pendingUpload,...ready}=node;
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
}) });
/** 仅改本地快照历史(含related),服务端事务条目仍由服务端重放解释。 */
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
}
/** 未上传且无关联边的图片可先在内存跨分区移动;校验容量/ID后按两owner原点和scale换算。
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
function movePendingImage(source: string, target: string, id: string) {
if(migrationBlocked())throw new Error("canvas_busy");
const from = current.current.get(source)!, to = current.current.get(target)!;
@@ -413,6 +506,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
}
/** 独占等待writer停止后读新快照并三方合并;内容指纹改变才记本地历史,再安装新ACK。 */
async function refresh(boardId: string) {
return exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) return;
@@ -423,6 +517,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
});
}
/** 显式采用生成结果前,针对目标节点把基线text/src/assetId/ai对齐到远端,再合并其它本地编辑。
* 合并结果先排空保存,后续apply命令才能在明确版本上覆盖目标;此方法本身不发送生成结果采用请求。 */
async function prepareApply(boardId: string, nodeId: string) {
await exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
@@ -436,12 +532,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
setError("");
});
}
/** 查看栈顶并检查准入,成功后才移动栈项;本地条目替换before,事务条目调用服务端undo或重做。
* 重做可能创建新子board,使用alias将后续历史引用转到新身份,避免继续操作已归档旧子图。 */
async function undoAction(isRedo = false) {
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
/** 沿别名链解析最新boardID并防循环;只用于历史重放,不篡改任意元素ID。 */
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
/** 重做本地快照时更新子图board引用,其它节点内容保留。 */
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
// 待上传图片若已存在于writer基线,纯本地快照切换已不够,需按归属路径逐跳请求成员移动后再转栈。
if (!("id" in entry) && entry.pendingMove) {
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
@@ -452,6 +553,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
stack.pop();destination.push(entry);tick((v)=>v+1);return;
}
}
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
if ("id" in entry) {
if (isRedo) {
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
@@ -464,6 +566,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
stack.pop(); destination.push(entry);
}
// 普通本地历史用当前文档作为反向条目,连同related画布一起发布,保证下一次redo可还原。
} else {
const before = current.current.get(entry.boardId); if (!before) return;
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
@@ -472,6 +575,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
tick((v) => v + 1);
}
/** 重试普通writer并触发子图读取effect;不在这里自动重发成员任务或未确认command,它们有各自入口。 */
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
// 对外暴露投影、动作、恢复、事务及writer引用,旧版UI依赖此组合接口;busy合并独占、待确认命令与迁移门闩。
// transactionBusy/canUndo/canRedo反映成员队列与栈是否有项,最终undoAction仍再次检查实际准入。
return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
}
+34
View File
@@ -1,95 +1,121 @@
/* 当前 LandingPage 的局部配色、两栏首屏、可选预览卡片、功能区和响应式布局;预览交互由组件状态驱动,不使用真实画布模型。 */
/* 页面作用域内定义墨色/弱文字/边线变量,局部规则覆盖全局品牌类时使用 :global。 */
.page { --ink: #21221f; --muted: #797b73; --line: #e5e6df; background: #fff; color: var(--ink); font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
/* 移除触摸高亮并保留明确的键盘可见焦点描边。 */
.page a, .page button { -webkit-tap-highlight-color: transparent; }
.page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; }
/* 导航内容限制最大宽度,品牌、导航链接和右侧操作区横排。 */
.nav { display: flex; align-items: center; gap: 40px; height: 88px; max-width: 1320px; padding: 0 48px; margin: auto; }
.page :global(.brand) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; }
.page :global(.brand img) { width: 30px; height: 30px; }
.page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; }
.nav nav { display: flex; gap: 28px; font-size: 13px; color: #72766c; }
.nav nav a:hover { color: var(--ink); }
/* margin-left:auto 将登录和小 CTA 推到最右侧,hover 只改变表面色。 */
.navActions { margin-left: auto; display: flex; align-items: center; gap: 24px; font-size: 13px; }
.smallCta { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; }
.smallCta:hover { background: #f5f6f0; }
/* 桌面首屏文案/预览用接近等宽的两列;clamp 让标题在边界内随视口缩放。 */
.hero { max-width: 1320px; margin: auto; padding: 82px 48px 90px; display: grid; grid-template-columns: 1fr 1.04fr; align-items: center; gap: 46px; }
.eyebrow { font: 10px/1.6 ui-monospace, "SFMono-Regular", monospace; letter-spacing: 1.25px; color: #90958a; }
.heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; }
.hero h1 { font-size: clamp(38px, 4.4vw, 61px); font-weight: 600; line-height: 1.23; letter-spacing: -3px; margin: 22px 0; }
.hero h1 > span { position: relative; white-space: nowrap; }
/* 标题装饰线通过伪元素绘制,不增加实际文本或影响行内宽度。 */
.hero h1 > span::after { content: ""; position: absolute; left: 0; bottom: -3px; width: 100%; height: 5px; border-bottom: 2px solid #b9c87c; border-radius: 50%; rotate: -2deg; }
.heroCopy > p { color: #6f7369; font-size: 15px; line-height: 1.9; font-weight: 400; }
/* CTA 可换行,主按钮悬停上移 1px;旁边文字链接保持次级视觉权重。 */
.heroActions { display: flex; flex-wrap: wrap; align-items: center; gap: 22px; margin-top: 30px; }
.primary { display: inline-flex; align-items: center; justify-content: center; gap: 16px; padding: 14px 20px; background: var(--ink); color: #fff; border: 1px solid var(--ink); border-radius: 5px; font-size: 13px; font-weight: 500; box-shadow: 0 2px 2px #21221f08; transition: background 160ms, translate 160ms; }
.primary:hover { background: #41453b; translate: 0 -1px; }
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
.textLink:hover { color: #21221f; }
/* 手写风附注和星形装饰仅参与视觉排版。 */
.heroNote { margin-top: 34px; font-family: "Kaiti SC", "STKaiti", Georgia, serif; color: #969a8b; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.scribbleStar { font-family: Georgia, serif; color: #929a74; font-size: 24px; }
/* 预览外壳轻微旋转并设置阴影,min-width:0 防止网格项被内容撑宽。 */
.preview { min-width: 0; border: 1px solid #e0e3d9; border-radius: 9px; background: #fafafa; box-shadow: 0 12px 50px #3c442508, 0 2px 8px #3c442505; rotate: -1deg; }
.previewHeading { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; font-size: 10px; color: #777d70; }
.previewHeading > span:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; }
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
.tinyLogo { font-size: 20px; color: #4c5841; }
/* 点阵预览固定宽高比,卡片和 SVG 连线在相同相对容器内绝对定位。 */
.scene { position: relative; aspect-ratio: 600 / 440; background-image: radial-gradient(#dce1d2 0.65px, transparent 0.65px); background-size: 17px 17px; }
.wires { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; }
.wires circle { fill: #a3ad90; }
/* 预览卡片作为按钮可点击,hover 上移,activeCard 通过边框/阴影反馈当前选择。 */
.scene > button { position: absolute; display: flex; flex-direction: column; text-align: left; border: 1px solid #e1e4d8; border-radius: 5px; padding: 14px; background: #fff; color: #373a35; box-shadow: 0 2px 5px #32471504; transition: box-shadow 180ms, border-color 180ms, translate 180ms; }
.scene > button:hover { translate: 0 -3px; }
.scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; }
.cardLabel { display: flex; align-items: center; gap: 5px; font-size: 9px; color: #9aab88; }
/* 三类预览卡片各有位置、旋转和排版;纸角、艺术图与标签是局部装饰。 */
.ideaCard { left: 5%; top: 17%; width: 42%; min-height: 41%; rotate: -3deg; }
.ideaCard strong { margin-top: 14px; margin-bottom: 13px; font-size: clamp(13px, 1.6vw, 22px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.55; }
.cardText { font-size: 9px; line-height: 1.8; color: #9ba48f; }
.paperCorner { position: absolute; bottom: 4px; right: 4px; width: 10px; height: 10px; border-right: 1px solid #d9dece; border-bottom: 1px solid #d9dece; }
/* 艺术卡片给 SVG 可用高度,使用无填充线稿;便签另有底色与字号。 */
.scene .artCard { top: 8%; right: 7%; width: 29%; min-height: 49%; background: #edeaf7; rotate: 4deg; }
.artCard svg { width: 100%; max-height: 150px; flex: 1; stroke: #53584d; stroke-width: 1.2; fill: none; }
.artCaption { font: italic 8px Georgia, serif; text-align: center; color: #909e79; }
.scene .noteCard { left: 38%; bottom: 9%; width: 37%; min-height: 27%; background: #f0eedf; rotate: 2deg; }
.noteCard strong { margin: 9px 0 12px; font-size: clamp(11px, 1.4vw, 18px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.5; }
.noteTag { align-self: flex-start; font-size: 8px; padding: 3px 5px; border: 1px solid #d6d6ba; border-radius: 3px; color: #8f9278; }
/* 随手附注与假协作光标定位在预览中,光标不拦截鼠标事件。 */
.looseNote { position: absolute; left: 8%; bottom: 12%; color: #aab39c; rotate: -10deg; font: italic 14px Georgia, serif; }
.cursor { position: absolute; right: 17%; bottom: 20%; color: #879567; rotate: -12deg; pointer-events: none; }
.cursor span { position: absolute; top: 20px; left: 12px; background: #879567; color: white; border-radius: 3px; padding: 3px 7px; font-size: 9px; }
/* 预览页脚横排指示点与页码,aria-pressed 标示当前点,最后一项推到右侧。 */
.previewFooter { display: flex; align-items: center; gap: 10px; padding: 13px 18px; border-top: 1px solid var(--line); font-size: 9px; color: #a1a992; }
.previewFooter > div { display: flex; gap: 5px; }
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
.previewFooter button[aria-pressed="true"] { background: #879567; }
.previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; }
/* 宣言横条用上下边线与两侧注记分隔首屏和后续内容。 */
.manifestoStrip { display: flex; align-items: center; justify-content: space-between; gap: 20px; max-width: 1224px; margin: 0 auto; padding: 25px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.manifestoStrip > span { font: 8px ui-monospace, monospace; letter-spacing: 0.6px; color: #a0a494; }
.manifestoStrip p { font-size: 13px; color: #646c58; letter-spacing: 0.3px; }
.manifestoStrip p span { color: #9ba28e; }
/* 功能区域限制最大宽度;标题与说明左右对齐,功能卡使用三列网格。 */
.space { max-width: 1320px; margin: auto; padding: 105px 48px; }
.sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 48px; }
.sectionHeading h2, .faq h2 { font-size: 34px; line-height: 1.5; letter-spacing: -1px; font-weight: 500; margin-top: 18px; }
.sectionHeading > p { padding-bottom: 5px; color: #6c7166; line-height: 1.9; font-size: 13px; }
.featureGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
.featureGrid article { min-width: 0; }
/* 每个功能插画使用固定高度裁剪容器,内部收集/连线/创作布局分别绘制。 */
.featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; }
/* 收集示例用轻微旋转和水平位移错开三条素材,保持纵向居中。 */
.collectArt { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; }
.collectArt span { display: flex; align-items: center; gap: 9px; padding: 8px 12px; min-width: 70%; border: 1px solid #e1e6d7; border-radius: 4px; background: #fff; font-size: 10px; color: #859471; rotate: -2deg; }
.collectArt span:nth-child(2) { rotate: 2deg; translate: 8px 0; }
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
.collectArt svg { width: 14px; }
/* 连线示例 SVG 铺满插画,两个端点卡片和装饰字形单独定位。 */
.connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; }
.connectArt span { position: absolute; top: 39px; left: 23px; padding: 10px 13px; border: 1px solid #e1e5d6; border-radius: 5px; background: #fff; color: #8a9877; font-size: 11px; }
.connectArt span:nth-of-type(2) { top: 110px; left: auto; right: 35px; background: #edf0e1; }
.connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; }
/* 创作示例居中纸张和标题,编辑徽标独立固定在插画右下附近。 */
.createArt { display: grid; place-items: center; }
.createArt > div { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; background: #f1efe3; width: 72%; rotate: -2deg; border: 1px solid #e0dfcf; }
.createArt > div > span { font: 7px ui-monospace, monospace; letter-spacing: 1px; color: #9da184; }
.createArt strong { font: 25px "Songti SC", Georgia, serif; color: #6e7857; letter-spacing: 1px; }
.editBadge { position: absolute; right: 14px; bottom: 24px; display: flex; align-items: center; gap: 5px; padding: 5px 8px; border: 1px solid #dce3cf; background: #fff; border-radius: 4px; font-size: 8px; color: #889874; }
/* 编号、功能标题与正文使用逐级字号,正文行高便于多行阅读。 */
.featureNumber { color: #a4ab98; font: 9px ui-monospace, monospace; letter-spacing: 0.7px; }
.featureGrid h3 { font-size: 18px; font-weight: 500; margin: 12px 0; letter-spacing: -0.4px; }
.featureGrid p { font-size: 12px; color: #6c7166; line-height: 1.9; }
/* 工作流区域用浅底与上下边线分隔,两列分别承载引语和步骤。 */
.workflow { background: #f7f7f4; border-top: 1px solid #e9ece1; border-bottom: 1px solid #e9ece1; display: grid; grid-template-columns: 1fr 1fr; gap: 90px; padding: 85px max(48px, calc((100vw - 1224px) / 2)); }
/* 引号绝对定位在文案旁,标题自身保持相对定位,签名字体与正文区分。 */
.quote { position: relative; }
.quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; }
.quote h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; }
.quote em { display: block; font-style: normal; }
.quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; }
.signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; }
/* 步骤纵向居中,每项含编号、文字和右侧不收缩的图标。 */
.workflowList { display: flex; flex-direction: column; justify-content: center; }
.workflowList > div { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; }
.workflowList > div:first-child { padding-top: 0; }
@@ -97,11 +123,13 @@
.workflowList h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; }
/* FAQ 桌面为标题/问题两列,问题按钮整行可点击;展开内容是否存在由组件决定。 */
.faq { max-width: 1320px; padding: 105px 48px; margin: auto; display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 90px; }
.faqItem { border-bottom: 1px solid var(--line); }
.faqItem button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 23px 0; font-size: 14px; text-align: left; }
.faqItem svg { flex-shrink: 0; color: #929b84; transition: rotate 160ms; }
.faqItem p { padding: 0 28px 24px 0; font-size: 12px; line-height: 1.9; color: #8b947e; }
/* 收尾 CTA 居中,星形装饰绝对定位;页脚内容横排并用 auto margin 推开链接。 */
.closing { position: relative; border-top: 1px solid var(--line); text-align: center; padding: 85px 24px 90px; }
.closing h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; }
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
@@ -110,8 +138,11 @@
.footer > p { font-size: 11px; color: #a1a992; }
.footer > a:not(:first-child) { margin-left: auto; display: flex; gap: 7px; align-items: center; font-size: 11px; color: #7e8b6a; }
.footer > span { color: #abb39d; font: 8px ui-monospace, monospace; }
/* 断行标记默认隐藏,本文件没有另行显示它的媒体查询。 */
.mobileBreak { display: none; }
/* 大屏仅增大首屏上下留白。 */
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
/* 中屏压缩页面边距、标题、按钮及区块间距,仍保留主要双栏/三栏结构。 */
@media (max-width: 1050px) {
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
.hero { gap: 26px; }
@@ -124,6 +155,7 @@
.featureGrid { gap: 20px; }
.faq { gap: 45px; }
}
/* 窄屏隐藏导航链接,将首屏、功能、工作流和 FAQ 转为单列,并缩小装饰及页脚内容。 */
@media (max-width: 760px) {
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
.nav nav { display: none; }
@@ -159,6 +191,7 @@
.footer > p { display: none; }
.footer > span { flex-basis: 100%; }
}
/* 极窄屏进一步压缩边距和预览卡片文字,隐藏品牌副标,避免最小视口拥挤。 */
@media (max-width: 380px) {
.page :global(.brand small) { display: none; }
.hero { padding-left: 20px; padding-right: 20px; }
@@ -170,4 +203,5 @@
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
.noteTag { font-size: 7px; padding: 2px; }
}
/* 按减少动态效果偏好关闭链接、按钮和 FAQ 图标的过渡。 */
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
+5
View File
@@ -1,3 +1,5 @@
/* IconButton 的共享尺寸、交互态和 tooltip 外观;尺寸从所在 Toolbar 的自定义属性继承,缺省为 32px 控件/20px 图标。 */
/* 固定 flex 尺寸避免拥挤时压缩;inline-flex 居中图标,透明背景显露宿主表面,文字颜色继承宿主。 */
.button {
display: inline-flex;
align-items: center;
@@ -13,10 +15,13 @@
color: inherit;
cursor: pointer;
}
/* SVG 同时约束宽高与 flex-basis,防止图标在文字/布局挤压下变形。 */
.button svg { width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); flex: 0 0 var(--toolbar-icon-size, 20px); }
/* 悬停填底、键盘可见焦点描边、禁用态降低透明度并提示不可操作。 */
.button:hover { background: #e9e7e3; }
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
.button:disabled { opacity: 0.45; cursor: not-allowed; }
/* tooltip 使用高层级深色面板与有限宽度;arrow 颜色与面板一致。 */
.tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; }
.arrow { fill: #49372e; }
+4
View File
@@ -1,3 +1,5 @@
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
.surface {
--toolbar-icon-size: 20px;
--toolbar-control-width: 32px;
@@ -18,5 +20,7 @@
}
/* Text in the view controls already creates wider visual spacing. */
/* 含缩放文字的视图工具栏增加横向留白,不改变控件本身的尺寸。 */
.relaxed { column-gap: 6px; padding-inline: 6px; }
/* 分隔线保持 1px flex 基准,避免被相邻控件挤宽或挤没。 */
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
@@ -1,9 +1,15 @@
/* EditableTitle 的只读标题/输入框切换样式;壳体作为错误提示的定位参照,网络和提交状态由组件控制。 */
/* 相对定位保留标题所在行,绝对定位错误框不会把工具栏向下撑开。 */
.shell { position: relative; display: flex; align-items: center; }
/* 两种状态共用高度、字体和透明边框,降低进入编辑时的尺寸跳变。 */
.title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; }
/* 非编辑标题单行省略;悬停仅作用于可用按钮,选中态由 data-selected 控制。 */
.title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
.title:hover:not(:disabled) { background: #f0ece6; }
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
.title:disabled { cursor: default; }
/* 编辑态给定目标宽度但受上方 max-width 限制,readonly 时降低透明度。 */
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
.input[readonly] { opacity: 0.65; }
/* 错误框显示在标题下方,并以视口宽度限制最大宽度;长说明允许换行。 */
.error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; }
@@ -1,3 +1,5 @@
/* 工作区壳体、目录、账户菜单与画布浮动标题;CSS Module 负责显示,账号/画布生命周期和侧栏开关由组件管理。 */
/* 侧栏宽度变量供侧栏本体和浮动标题共同消费,根容器至少占满动态视口高度。 */
.shell {
--sidebar-width: 270px;
display: flex;
@@ -5,6 +7,7 @@
background: #fffefb;
color: #37352f;
}
/* 侧栏插槽 sticky 固定在滚动视口顶部;裁剪展开过渡期间的内容,层级高于画布。 */
.sidebarSlot {
position: sticky;
top: 0;
@@ -14,6 +17,7 @@
overflow: hidden;
z-index: 30;
}
/* 侧栏采用纵向 flex,导航区可滚动、账户区留在底部;最大宽度给主区域保留至少 40px。 */
.sidebar {
display: flex;
flex-direction: column;
@@ -29,30 +33,38 @@
letter-spacing: -0.15px;
color: #37352f;
}
/* 顶行按钮分居两端;搜索框 focus-within 让输入聚焦时整个框有反馈。 */
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; }
.search { display: flex; align-items: center; gap: 7px; min-height: 32px; border: 1px solid #e6e3df; border-radius: 7px; padding: 4px 8px; color: #97938d; }
.search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; }
.search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
.search input::placeholder { color: #9a9690; }
/* flex:1 配合 min-height:0 才允许长目录在侧栏内部滚动。 */
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
/* 首页与目录链接共用行高/图标间距,长标题省略;aria-current 对当前页提供选中背景。 */
.home, .pageLink { display: flex; align-items: center; gap: 8px; padding: 4px 8px; min-height: 30px; border-radius: 6px; font-weight: 450; }
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
.pageLink { margin: 1px 0; }
.pageLink svg { flex-shrink: 0; color: #87837c; }
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
/* 分组名称、空态提示和错误分别排版;错误允许在任意位置折行。 */
.sectionLabel { display: flex; justify-content: space-between; padding: 18px 7px 6px; color: #9b9690; font-size: 11px; font-weight: 400; letter-spacing: 0; }
.hint { padding: 4px 8px; color: #9a948c; line-height: 1.6; font-size: 12px; }
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
/* 账户区域向侧栏底部靠齐,按钮占满宽度以便整行点击。 */
.accountArea { margin-top: auto; padding-top: 6px; }
.accountButton { display: flex; align-items: center; gap: 8px; padding: 6px 7px; min-height: 36px; width: 100%; border-radius: 6px; text-align: left; color: #37352f; }
.accountButton:hover { background: #edeae5; }
/* 头像固定尺寸并裁成圆形,图片 object-fit:cover;缺图时容纳首字占位。 */
.avatar { display: grid; place-items: center; flex: 0 0 24px; width: 24px; height: 24px; overflow: hidden; border-radius: 50%; background: #e8e0d6; color: #705844; font-size: 12px; font-weight: 600; }
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
/* 账户文字允许收缩和省略,副文字在此布局隐藏。 */
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
.accountText strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountText small { display: none; }
/* 原生 popover 的面板位置/宽度约束;自身可滚动且从左下角进入,不用 CSS 切换 open 状态。 */
.accountPopup {
position: fixed;
inset: auto auto 50px 8px;
@@ -73,31 +85,40 @@
letter-spacing: -0.15px;
transform-origin: bottom left;
}
/* 仅 popover 打开时播放进入动画;backdrop 保持透明,但是否可交互仍由 popover 行为决定。 */
.accountPopup:popover-open { animation: account-enter 130ms ease-out; }
.accountPopup::backdrop { background: transparent; }
/* 菜单头、工作区字母头像及身份行共用纵向文字布局,长名称省略。 */
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; }
.workspaceAvatar { display: grid; place-items: center; flex: 0 0 36px; height: 36px; border-radius: 7px; color: #72533e; background: #eee8df; font-size: 19px; font-weight: 600; }
.accountHeading > div, .popupIdentity > div { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.accountHeading strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountHeading small { color: #8b8780; font-size: 12px; }
/* 菜单分组和身份区域提供内间距,邮箱允许断行;工作区列表限制高度后独立滚动。 */
.menuGroup { padding: 1px 0; }
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
.popupIdentity strong { font-size: 12px; font-weight: 500; }
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; }
/* 菜单项整行可点,文字可收缩,图标保持宽度;disabled 态显式降低透明度。 */
.menuItem { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 31px; padding: 5px 8px; border-radius: 5px; text-align: left; font-size: 13px; line-height: 20px; font-weight: 450; }
.menuItem > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menuItem > svg { flex-shrink: 0; }
.menuItem:hover { background: #f1f0ed; }
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; }
/* 工作区小头像与新建/登出颜色区分,separator 分隔组而不引入额外布局容器。 */
.workspaceMini { display: grid; place-items: center; flex: 0 0 20px; height: 20px; border-radius: 4px; color: #82634c; background: #f1ebe3; font-size: 11px; font-weight: 600; }
.newWorkspace { color: #2383e2; }
.logout { color: #78746d; font-size: 12px; }
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
/* 左上浮动条以 --floating-left 定位;侧栏展开时偏移到侧栏右侧,位置和宽度作 240ms 过渡。 */
.floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
/* 展开侧栏时使用实际受视口约束的侧栏宽度计算偏移;账户 popover 也继承同一偏移变量。 */
.floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); }
/* 浮动条错误绝对定位于条下方,账户设置面板则沿用对话框容器。 */
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
/* 设置表单纵向排布身份、字段及操作;提交按钮区分主动作,禁用时保留位置。 */
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
.settingsIdentity { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #89837b; overflow-wrap: anywhere; }
.settingsForm label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 500; }
@@ -106,35 +127,48 @@
.settingsActions button { padding: 6px 12px; border: 1px solid #e2ddd6; border-radius: 6px; font-size: 13px; }
.settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
/* 固定全视口画布置于底层;点阵背景与加载/错误状态由后续类分别承担。 */
.graph { position: fixed; inset: 0; z-index: 0; display: flex; width: 100%; height: 100dvh; background: #fffefb; }
.graphSurface { width: 100%; height: 100%; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
.graphStatus { position: absolute; left: 50%; top: 72px; transform: translateX(-50%); max-width: calc(100vw - 48px); padding: 8px 12px; border-radius: 6px; background: #fffefbe6; color: #9a948c; text-align: center; font-size: 13px; }
/* 账户菜单从轻微下移/缩小过渡到正常位置与不透明状态。 */
@keyframes account-enter { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
/* 较窄视口将侧栏目标宽度收为 240px;仍受上方 max-width 约束。 */
@media (max-width: 600px) { .shell { --sidebar-width: 240px; } }
/* 减少动态效果偏好下关闭菜单进入动画;浮动条过渡在文件后部单独关闭。 */
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
/* 浮动条 logo 链接沿用 Toolbar 控件尺寸与键盘焦点样式,保证和旁边按钮对齐。 */
.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); padding: 4px 6px; border-radius: 5px; }
.logoLink img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); }
.logoLink:hover { background: #e9e7e3; }
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
/* 浮动头像按钮同样使用共享尺寸;从浮动条打开的账户面板改为顶端定位。 */
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; }
.floatingAvatarButton:hover { background: #e9e7e3; }
/* clamp 把账户面板左边界限制在视口内;transform-origin 随顶部锚点改为左上。 */
.accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; }
/* 浮动头像采用图标尺寸,标题允许 flex 收缩但保留溢出区域给编辑错误提示。 */
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
/* 中等窄屏降低标题最大宽度,不改变标题内容或编辑状态。 */
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
/* 极窄且侧栏仍展开时隐藏 logo/标题/分隔,保留浮动条其余控件。 */
@media (max-width: 420px) {
.floating[data-sidebar-collapsed="false"] .logoLink,
.floating[data-sidebar-collapsed="false"] .floatingTitle,
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
}
/* 按用户偏好关闭浮动条位移/宽度过渡。 */
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
/* 递归目录清除列表默认缩进,层级缩进由组件布局提供;行本身允许标题收缩。 */
.directoryList { list-style: none; padding: 0; margin: 0; }
.directoryRow { display: flex; align-items: center; min-width: 0; }
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
/* 可折叠项按钮与叶项占位共享 20px 列宽,保持各行标题对齐;焦点内描边不扩尺寸。 */
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
.directoryToggle:hover { background: #eae7e2; }
File diff suppressed because it is too large Load Diff
+40
View File
@@ -1,10 +1,17 @@
/**
* 已构建 Next 应用的本地 HTTP smoke:启动临时 Foundry 替身和 next start,遍历页面、代理、资源及认证响应。
* 需要已有可用构建,运行会占用 127.0.0.1:3005;脚本不执行 next build,也不连接真实 Foundry/模型服务。
* 断言以 HTTP、文件字节及内存状态为准,不验证浏览器交互、真实登录提供商或数据库事务。
*/
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { readdir, readFile } from "node:fs/promises";
// 应用使用固定端口;upstream 另选随机端口,由子进程环境变量指定为代理源站。
const host = "http://127.0.0.1:3005";
// 图谱、画布版本、会话剩余秒数与 profile 都是本次进程的内存夹具。
const graphs = new Map();
let releaseStream = () => {};
let canvasSnapshot = { version: 1, document: { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } };
@@ -13,23 +20,28 @@ let mockProfile = {
id: "user", username: "test", display_name: "Test User", email: "test@example.com",
avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c",
};
// 最小 Foundry HTTP 替身:有效测试会话才发续期提示,并按路由返回页面代理需要的响应。
const upstream = createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.headers.authorization === "Bearer test-only-session" && !req.url.endsWith("/auth/logout")) res.setHeader("X-Foundry-Session-Expires-In", mockSessionSeconds);
// 把 workspace/board slug 归一到同一记录,供四种 ID/slug 组合读取校验;不是通用路由解析器。
const canonical = req.url.replace("/workspaces/creative-space/", "/workspaces/space/").replace("/materials/my-board", "/materials/foundry-generated-id");
if (req.url.includes("/canvas")) assert.equal(req.headers.accept, "application/vnd.foundry.canvas+json;version=2");
// 资源 GET 返回短 PNG 头字节或不支持 MIME;POST 汇总 multipart 原始字节并回显幂等键。
if (req.url.startsWith("/projects/endoleap/workspaces/space/assets")) {
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({error:"invalid_session"})); return; }
if (req.method === "GET") { res.setHeader("X-Asset-Variant", new URL(req.url,"http://mock").searchParams.get("variant") === "preview" ? "preview" : "original"); res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; }
let bytes = Buffer.alloc(0); for await (const chunk of req) bytes = Buffer.concat([bytes, chunk]);
res.end(JSON.stringify({type:req.headers["content-type"],bytes:bytes.length,key:req.headers["idempotency-key"],multipart:bytes.includes(Buffer.from('name="image"'))})); return;
}
// 先写 JSON 前缀,再延迟 15.2 秒完成 body,用于检查旧总超时不截断合法传输。
if (canonical.endsWith("/materials/slow-body-board/canvas")) {
res.setHeader("ETag", '\"210\"');
res.write('{"version":210,"document":');
await new Promise((resolve) => setTimeout(resolve, 15200));
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
}
// 由 releaseStream 控制尾部放行:代理必须在完整 JSON 可用之前转交头和首块。
if (canonical.endsWith("/materials/stream-board/canvas")) {
assert.equal(req.headers.authorization, "Bearer test-only-session");
res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2");
@@ -37,9 +49,11 @@ const upstream = createServer(async (req, res) => {
await new Promise((resolve) => { releaseStream = resolve; });
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
}
// V2 子命令只回显 URL、方法与关键头;这个分支不执行真正的连接、AI 或结构事务。
if (canonical.includes("/canvas/")) {
res.end(JSON.stringify({url:req.url,method:req.method,accept:req.headers.accept,key:req.headers["idempotency-key"],version:req.headers["if-match"]})); return;
}
// 旧完整快照 GET/PATCH 独立维护版本,If-Match 过期时返回 412;不与图谱元信息共用版本。
if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id/canvas") {
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({ error: "invalid_session" })); return; }
if (req.method === "GET") { res.setHeader("X-Canvas-Schema-Version", String(canvasSnapshot.document.schema_version)); res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; }
@@ -50,6 +64,7 @@ const upstream = createServer(async (req, res) => {
canvasSnapshot = { version: canvasSnapshot.version + 1, document };
res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify({ version: canvasSnapshot.version })); return;
}
// 其余分支提供登出、图谱创建/重命名、登录 token 和 profile;未知路由返回 404。
if (req.url.endsWith("/auth/logout")) {
res.statusCode = 204;
res.end();
@@ -59,6 +74,7 @@ const upstream = createServer(async (req, res) => {
for await (const chunk of req) raw += chunk;
const input = JSON.parse(raw);
assert.deepEqual(input, { kind: "board", title: "未命名图谱", content: { schema_version: 1 } });
// 图谱 ID 由替身分配,同时保留 slug;创建响应只回身份字段,详细内容需后续 GET。
const graph = { ...input, slug: "my-board", id: "foundry-generated-id", workspace_id: "space", version: 1 };
graphs.set(graph.id, graph);
res.statusCode = 201;
@@ -100,8 +116,10 @@ const upstream = createServer(async (req, res) => {
res.end("{}");
}
});
// 先监听随机端口再启动 Next 子进程,保证应用首次代理请求就能连到替身。
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
const port = upstream.address().port;
// 启动 next start 消费已有构建,捕获 stdout/stderr 供启动失败诊断,不运行开发服务器。
const app = spawn(
process.execPath,
[
@@ -118,10 +136,12 @@ const app = spawn(
stdio: ["ignore", "pipe", "pipe"],
},
);
// 收集子进程启动输出;轮询中若提前退出,错误包含这些诊断文本。
let startup = "";
app.stdout.on("data", (x) => (startup += x));
app.stderr.on("data", (x) => (startup += x));
try {
// 最多轮询 100 次,失败后等待 100ms,再检查首页是否可用;不能只凭 spawn 返回就宣称服务已启动。
let ready = false;
for (let n = 0; n < 100; n++) {
if (app.exitCode !== null)
@@ -135,6 +155,7 @@ try {
await new Promise((r) => setTimeout(r, 100));
}
assert(ready, "Frontend did not start");
// 逐页检查状态和公共元信息;这些是服务端 HTML 断言,不代表页面客户端交互已验证。
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1", "/dashboard/space/foundry-generated-id"]) {
const page = await fetch(host + path);
assert.equal(page.status, 200, path);
@@ -146,6 +167,7 @@ try {
assert(html.includes('/brand/icons/favicon.ico'));
assert(html.includes('/brand/icons/apple-touch-icon.png'));
}
// 逐个下载本地品牌资产并与仓库原字节比较,检查静态服务没有替换或遗漏文件。
const brandRoot = new URL("../public/brand/", import.meta.url);
const assets = (await readdir(brandRoot, { recursive: true })).filter((file) => /\.(png|ico|svg)$/.test(file));
assert.equal(assets.length, 88);
@@ -154,6 +176,7 @@ try {
assert.equal(response.status, 200, asset);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), await readFile(new URL(asset, brandRoot)), asset);
}
// 按 manifest 枚举图标分片,核对 immutable 缓存头和 JSON 内容,与目录数量的日志文案区分。
const iconManifest = JSON.parse(await readFile(new URL("../src/lib/canvas-icon-assets.json", import.meta.url), "utf8"));
for (const file of Object.values(iconManifest.assets)) {
const path = iconManifest.root + "/" + file;
@@ -163,6 +186,7 @@ try {
assert.deepEqual(await response.json(), JSON.parse(await readFile(new URL("../public" + path, import.meta.url), "utf8")));
}
assert.equal((await fetch(host + "/missing-page")).status, 404);
// 登录代理必须去掉响应体 token,改设 HttpOnly/SameSite cookie,并报告上游计时。
const res = await fetch(host + "/api/foundry/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: host },
@@ -190,6 +214,7 @@ try {
while (true) { const next = await reader.read(); if (next.done) break; raw += new TextDecoder().decode(next.value); }
assert.equal(JSON.parse(raw).document.schema_version, 2);
} finally { clearTimeout(streamTimer); releaseStream(); }
// 子命令循环只检查头透传;随后检查资源 variant、目录分页与 AI 查询参数白名单。
const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas";
const idem = "11111111-1111-4111-8111-111111111111";
for (const path of ["connections", "subgraphs", "subgraphs/migrate", "subgraphs/frame-1/members", "subgraphs/frame-1/dissolve", "transactions/tx-1/undo", "ai/runs", "ai/runs/run-1/cancel", "ai/runs/run-1/apply"]) {
@@ -202,6 +227,7 @@ try {
assert.equal(directoryQuery.get("include_subgraphs"), "true"); assert.equal(directoryQuery.get("offset"), "100");
const events = await (await fetch(v2Base + "/ai/runs/run-1?after_seq=12&limit=100&status=active&include_descendants=true&cursor=cursor&secret=drop", { headers: { Cookie: session } })).json();
const query = new URL(events.url, "http://mock").searchParams; assert.equal(query.get("after_seq"), "12"); assert.equal(query.get("include_descendants"), "true"); assert.equal(query.get("cursor"), "cursor"); assert.equal(query.get("secret"), null);
// 验证慢 body 完整读取和 trace 标识,再检查资源认证、MIME 拒绝、nosniff 与 multipart 转发。
const slowSnapshot = await fetch(host + "/api/foundry/workspaces/space/materials/slow-body-board/canvas", { headers: { Cookie: session } });
assert.ok(slowSnapshot.headers.get("x-canvas-trace-id")); assert.equal((await slowSnapshot.json()).version, 210, "A valid body can finish after the old 15-second total deadline");
const asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } });
@@ -211,6 +237,7 @@ try {
const form = new FormData(); form.set("image", new Blob([new Uint8Array([137,80,78,71])], { type: "image/png" }), "test.png");
const upload = await (await fetch(host + "/api/foundry/workspaces/space/assets", { method: "POST", headers: { Cookie: session, Origin: host, "Idempotency-Key": idem }, body: form })).json();
assert.match(upload.type, /^multipart\/form-data; boundary=/); assert.equal(upload.key, idem); assert.ok(upload.multipart);
// 有效会话首页重定向且不缓存私有结果;dashboard 初始 HTML 不应包含画布或 landing。
const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" });
assert.equal(signedInHome.status, 307);
assert.equal(signedInHome.headers.get("location"), "/dashboard");
@@ -222,6 +249,7 @@ try {
assert.match(dashboardHtml, /aria-label="(?:工作空间侧边栏|正在加载侧边栏)"/);
assert(!dashboardHtml.includes('data-testid="rf__wrapper"'));
assert(!dashboardHtml.includes('class="landing"'));
// 无会话或过期会话首页保持 landing,避免错误重定向到个人工作区。
for (const guestHeaders of [{}, { Cookie: "foundry_session=expired-session" }]) {
const guestHome = await fetch(host + "/", { headers: guestHeaders, redirect: "manual" });
assert.equal(guestHome.status, 200);
@@ -240,6 +268,7 @@ try {
200,
);
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
// 续期只接受有效剩余秒数;非法上游值和无效会话不应产生新的 Set-Cookie。
mockSessionSeconds = "3600";
const renewed = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } });
assert.equal(renewed.status, 200); assert.match(renewed.headers.get("set-cookie") || "", /Max-Age=3600/);
@@ -251,6 +280,7 @@ try {
const expiredSession = await fetch(host + "/api/foundry/profile", { headers: { Cookie: "foundry_session=expired" } });
assert.equal(expiredSession.status, 401); assert.equal(expiredSession.headers.get("set-cookie"), null);
// 读取头像再修改公开资料,确认未涉及的头像字段保持。
const profile = await (await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } })).json();
assert.equal(profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar=s96-c");
const updated = await fetch(host + "/api/foundry/profile", {
@@ -263,6 +293,7 @@ try {
assert.equal(updatedProfile.display_name, "Updated name");
assert.equal(updatedProfile.avatar_url, profile.avatar_url);
// 经真实代理创建、读取和重命名内存图谱,检查服务分配身份、内容保留和元信息版本递增。
const create = await fetch(host + "/api/foundry/workspaces/space/materials", {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
@@ -287,6 +318,7 @@ try {
assert.equal(renamed.version, 2);
assert.deepEqual(renamed.content, stored.content);
// workspace/board 各自使用 ID 或 slug 的四种组合都应定位同一图谱和独立画布版本。
for (const workspace of ["space", "creative-space"]) for (const board of ["foundry-generated-id", "my-board"]) {
const path = `/api/foundry/workspaces/${workspace}/materials/${board}`;
const material = await (await fetch(host + path, { headers: { Cookie: session } })).json();
@@ -296,6 +328,7 @@ try {
assert.equal((await snapshot.json()).version, 1);
}
const canvasPath = "/api/foundry/workspaces/creative-space/materials/my-board/canvas";
// 生成超过 1 MiB 的完整快照经代理保存/恢复,再用旧版本重发检查 412;不是增量或大文件性能测试。
const largeDocument = { schema_version: 1, nodes: Array.from({ length: 15 }, (_, index) => ({ id: "text-" + index, type: "material", position: { x: index * 300, y: 100 }, width: 260, height: 200, data: { kind: "text", title: "保存测试", text: "x".repeat(90000), fontSize: 24, rotation: 30, textStyle: { bold: true, color: "#123456" } } })), edges: [], viewport: { x: 20, y: 40, zoom: 0.75 } };
const savedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) });
assert.equal(savedCanvas.status, 200); assert.equal(savedCanvas.headers.get("etag"), '"2"');
@@ -304,6 +337,7 @@ try {
assert.deepEqual(restoredCanvas.document, largeDocument);
const conflict = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) });
assert.equal(conflict.status, 412); assert.equal((await conflict.json()).error, "version_conflict");
// 旧 frameId 成员关系及三种名称显示模式往返,逐次使用当前画布版本。
const frameDocument = structuredClone(largeDocument);
frameDocument.nodes.unshift({ id: "frame-1", type: "frame", position: { x: 0, y: 0 }, width: 640, height: 400, data: { kind: "frame", title: "参考分区", text: "", rotation: 0, labelMode: "fixed" } });
frameDocument.nodes[1].data.frameId = "frame-1";
@@ -317,10 +351,12 @@ try {
}
assert.equal((await fetch(host + canvasPath)).status, 401);
assert.equal((await fetch(host + "/api/foundry/workspaces/space/search/my-board/canvas")).status, 404);
// 超过画布 body 上限的请求必须返回 413,不能转成成功保存。
const oversizedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, "Content-Type": "application/json" }, body: JSON.stringify({ document: "x".repeat(5 * 1024 * 1024) }) });
assert.equal(oversizedCanvas.status, 413);
// 检查跨源拒绝、路径白名单、OAuth challenge、Secure cookie、非法 JSON、大小上限及方法过滤。
for (const origin of ["https://example.com", "null"])
assert.equal(
(
@@ -351,17 +387,20 @@ try {
assert.equal(invalid.status, status);
}
assert.equal((await fetch(host + "/api/foundry/profile", { method: "PUT" })).status, 405);
// 查询保留 kind/limit 而删除未许可 secret;此处使用替身回显观察最终上游路径。
const materials = await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&limit=100&secret=drop", {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}",
});
assert.deepEqual(await materials.json(), {
url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH",
});
// OAuth 只检查重定向地址与 challenge,不实际打开提供商或完成授权。
for (const provider of ["google", "slack"]) {
const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" });
assert.equal(redirect.status, 302);
assert.equal(redirect.headers.get("location"), `http://127.0.0.1:${port}/projects/endoleap/auth/${provider}?challenge=${"a".repeat(43)}`);
}
// 登出保持 204 空体并清除 cookie;下方 PASS 长文是摘要,实际覆盖以逐项断言为准。
const logout = await fetch(host + "/api/foundry/auth/logout", {
method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}",
});
@@ -371,6 +410,7 @@ try {
console.log(
"PASS: V2 commands, idempotency and query forwarding, authenticated binary assets and multipart uploads, Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
);
// 结束或断言失败均终止 Next 子进程并关闭替身监听;此处没有额外的强制退出超时。
} finally {
const exited = once(app, "exit");
app.kill("SIGTERM");
+8
View File
@@ -1,7 +1,13 @@
/**
* board 目录分页、树构建、祖先定位与筛选测试。请求由内存函数提供,不访问实际 workspace 服务。
* 列表顺序不保证父项先到;孤儿和循环节点仍须可访问,不能递归死循环。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadBoardDirectory, boardDirectory, activeBoardAncestors, filterBoardDirectory } from '../src/lib/board-directory.ts';
// 生成带 ID、slug 和 parent_board_id 的最小目录项,默认 title 使用 ID。
const board=(id,parent,title=id)=>({id,parent_board_id:parent,title,workspace_id:'w',kind:'board',version:1,slug:id+'-slug'});
// 第一页故意子先父后,下一页才给孙项;检查 include_subgraphs、offset 与 URL 编码,再检查层级、slug 祖先链和筛选路径。
test('directory includes subgraphs, traverses all pages and preserves nested ownership',async()=>{
const calls=[];const request=async(path)=>{calls.push(path);const offset=new URLSearchParams(path.split('?')[1]).get('offset');return offset==='0'?{items:[board('child','parent'),board('parent')],next_offset:100}:{items:[board('grandchild','child')],next_offset:null};};
const boards=await loadBoardDirectory(request,'space slug');assert.equal(calls.length,2);assert.ok(calls.every(path=>path.includes('include_subgraphs=true')));assert.ok(calls[1].endsWith('offset=100'));assert.ok(calls[0].includes('space%20slug'));
@@ -10,9 +16,11 @@ test('directory includes subgraphs, traverses all pages and preserves nested own
assert.deepEqual(filterBoardDirectory(roots,'grandchild')[0].children[0].children.map(item=>item.board.id),['grandchild']);
assert.equal(filterBoardDirectory(roots,'parent')[0].children.length,1);
});
// 父项缺失及双节点循环都提升为可见根项,子列表为空,避免丢失入口或无限嵌套。
test('missing parents and corrupt cycles remain accessible without recursive loops',()=>{
const roots=boardDirectory([board('orphan','missing'),board('a','b'),board('b','a')]);assert.deepEqual(roots.map(item=>item.board.id),['orphan','a','b']);assert.ok(roots.every(item=>item.children.length===0));
});
// 非递增 next_offset 明确拒绝;已经 abort 的信号由请求替身抛回,加载器不得吞掉取消。
test('directory rejects non-advancing pagination and propagates aborts',async()=>{
await assert.rejects(loadBoardDirectory(async()=>({items:[],next_offset:0}),'w'),/invalid_pagination/);
const controller=new AbortController();controller.abort();await assert.rejects(loadBoardDirectory(async(_path,options)=>{options.signal.throwIfAborted();},'w',controller.signal),{name:'AbortError'});
+10
View File
@@ -1,10 +1,17 @@
/**
* 旧画布附件上传助手和显示 URL 转换测试。请求为替身,检查 FormData、错误和资源地址,不是真实上传或图像解码。
* 这里的上传重试属于 canvas-assets helper,不可等同于 headless ledger 的持久冻结请求恢复。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { uploadCanvasImage } from '../src/lib/canvas-assets.ts';
import { managedImageSource } from '../src/lib/canvas-v2.ts';
// 固定 data URL 仅用来构造与对比二进制前缀,并非完整 PNG 解码样本。
const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
// 固定资产身份;返回值必须是项目管理的地址,任意外域响应不被当作受管上传结果。
const id='77777777-7777-4777-8777-777777777777';
const asset={id,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${id}`,width:1,height:1,bytes:24,mime_type:'image/png'};
// 首例直接比对 FormData.image 原字节、上传路径、POST 和幂等键形状,返回值保留服务资产元数据。
test('attachment upload sends the original file bytes',async()=>{
const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0;
const result=await uploadCanvasImage(async(path,options)=>{
@@ -13,15 +20,18 @@ test('attachment upload sends the original file bytes',async()=>{
},'w',new Blob([bytes],{type:'image/png'}),'image.png');
assert.equal(calls,1);assert.deepEqual(result,asset);
});
// 权限拒绝直接传播;格式类似但地址不受管理的成功响应也要拒绝,不能把任意 URL 写成上传结果。
test('attachment upload propagates permission failures and rejects unmanaged response URLs',async()=>{
const file=new Blob(['test'],{type:'image/png'});
await assert.rejects(uploadCanvasImage(async()=>{throw new Error('forbidden');},'w',file,'test.png'),/forbidden/);
await assert.rejects(uploadCanvasImage(async()=>({...asset,url:'https://invalid.example/a.png'}),'w',file,'test.png'),/invalid_asset_response/);
});
// 模拟第一次连接丢失、第二次成功;此例直接断言两次调用与相同幂等键,文件字节传递由首例单独检查。
test('uncertain upload retries the exact bytes with the same idempotency key',async()=>{
let calls=0,key;
await uploadCanvasImage(async(_path,options)=>{calls++;if(!key)key=options.headers['Idempotency-Key'];assert.equal(options.headers['Idempotency-Key'],key);if(calls===1)throw new TypeError('connection lost');return asset;},'w',new Blob(['test'],{type:'image/png'}),'test.png');assert.equal(calls,2);
});
// 受管原始 URL 转同源认证代理,preview 仅加显示变体参数;普通外部图片地址保持原样。
test('preview URLs are renderer-only and do not replace original managed references',()=>{
assert.equal(managedImageSource(asset.url),`/api/foundry/workspaces/w/assets/${id}`);assert.equal(managedImageSource(asset.url,true),`/api/foundry/workspaces/w/assets/${id}?variant=preview`);
assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png');
+14
View File
@@ -1,16 +1,23 @@
/**
* 旧 frameId 分区成员模型的拖入、随动与恢复测试;不是 headless ownerCanvasId 树或 linked-board 深子 canvas 的全部规则。
* 成员关系由明确 drop 决定,几何重叠本身不代表已经加入分区。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { assignFrameMembership, frameDropFeedback } from "../src/lib/canvas-frames.ts";
// frame、位于内部但尚未登记的 member,以及外部 icon;可分别检查 drop 准入和已有成员随动。
function seeded() {
const frame = makeCanvasNode("frame", { x: 100, y: 100 });
const member = makeCanvasNode("text", { x: 160, y: 160 });
const outsider = makeCanvasNode("icon", { x: 900, y: 120 });
return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } };
}
// 把给定快照包成空历史结构,保持所有测试通过相同 reducer 入口。
function state(document) { return { past: [], present: document, future: [] }; }
// 多种内容 drop 到重叠 frame 时预览应与最终成员选择一致;较小 frame 优先,移到外部则同时显示和执行 detach。
test("drop previews match actual assignment for every content type, exits and overlapping frames", () => {
const { frame } = seeded();
const smaller = makeCanvasNode("frame", { x: 120, y: 120 }); smaller.width = 300; smaller.height = 240;
@@ -26,6 +33,7 @@ test("drop previews match actual assignment for every content type, exits and ov
}
});
// 只给 dropped IDs 分配成员,拖 frame 或其他节点不能顺手收集原本重叠的内容。
test("only dropped content joins a frame; pre-existing overlapping content stays outside", () => {
const { frame, member, outsider, document } = seeded();
assert.equal(document.nodes[1].data.frameId, undefined);
@@ -38,6 +46,7 @@ test("only dropped content joins a frame; pre-existing overlapping content stays
assert.equal(assignFrameMembership([frame, member, outsider], [member.id])[2].data.frameId, undefined);
});
// 已有成员随 frame 同量移动,旁边 outsider 不改位置和归属;undo 恢复成员坐标与 frameId。
test("frames carry existing members without collecting newly overlapped content and undo restores positions", () => {
const { frame, member, outsider, document } = seeded();
member.data.frameId = frame.id;
@@ -51,6 +60,7 @@ test("frames carry existing members without collecting newly overlapped content
assert.equal(history.present.nodes[1].data.frameId, frame.id);
});
// 已有成员拖出清除 frameId,落进另一 frame 则换归属;以同一位置比较两种节点集合。
test("dragging out detaches content and dragging between frames reassigns membership", () => {
const { frame, member } = seeded();
member.data.frameId = frame.id;
@@ -60,6 +70,7 @@ test("dragging out detaches content and dragging between frames reassigns member
assert.equal(assignFrameMembership([frame, second, member], [member.id])[2].data.frameId, second.id);
});
// 同时上报父 frame 和成员的最终位置时,不能再把父位移叠加到成员,避免双重移动。
test("multi-selection does not move frame members twice", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
@@ -70,6 +81,7 @@ test("multi-selection does not move frame members twice", () => {
assert.deepEqual(result.present.nodes[1].position, { x: 260, y: 260 });
});
// 旧模型删除 frame 保留内容并清成员关联,undo 可恢复原 frameId;这不是 headless 删除 owning 子树的产品规则。
test("deleting a frame keeps its content and undo restores membership", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
@@ -80,6 +92,7 @@ test("deleting a frame keeps its content and undo restores membership", () => {
assert.equal(canvasReducer(removed, { type: "undo" }).present.nodes[1].data.frameId, frame.id);
});
// hidden/fixed/scaled 标签、名称与 frameId 往返保存,frame 渲染层序为 -1,设置历史可撤销。
test("all label modes, frame names and memberships survive save/restore and settings undo", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
@@ -95,6 +108,7 @@ test("all label modes, frame names and memberships survive save/restore and sett
}
});
// 缺失 frame、frame 作为别的内容成员、非法标签和 frame 旋转均拒绝;限制属于本旧格式。
test("invalid frame references, label modes, frame nesting and rotation are rejected", () => {
for (const mutate of [
({ member }) => { member.data.frameId = "missing"; },
+15
View File
@@ -1,12 +1,19 @@
/**
* 旧 CanvasElement 几何与能力测试,直接验证类、纯函数和旧 reducer;不分派真实 pointer 事件。
* 多方向/旋转样本用对边锚点及连接端点作为几何不变量,避免只比较宽高变化。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts";
import { nodeConnector, connectorBezier, nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.ts";
// 浮点误差容差为 1e-5,失败消息保留 actual/expected,供旋转与坐标换算诊断。
function close(actual, expected) { assert(Math.abs(actual - expected) < 0.00001, `${actual} != ${expected}`); }
// 将几何 patch 与旧 node.data 中的 rotation/fontSize 合并,重算中心和端点时使用同一模型形状。
function transformed(node, patch) { return { ...node, ...patch, data: { ...node.data, rotation: patch.rotation ?? node.data.rotation, fontSize: patch.fontSize ?? node.data.fontSize } }; }
// 三种卡片共享变换和 branching;PlainText 只继承公共变换,不能通过错误 ID 或自身 ID 绕过不连线/不分支能力。
test("card types inherit common element transforms and add branching; plain text inherits only common transforms", () => {
for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) {
const element = new Type({ x: 0, y: 0 });
@@ -27,12 +34,14 @@ test("card types inherit common element transforms and add branching; plain text
assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [node] }, node.id, "right"), /纯文本/);
});
// 五种旋转角度乘八方向 handle,检查受影响尺寸和相反局部锚点世界坐标保持不变。
test("all eight handles resize cards while retaining the opposite local anchor, including rotated cards", () => {
const directions = ["top", "right", "bottom", "left", "top-left", "top-right", "bottom-left", "bottom-right"];
for (const rotation of [0, 37, 90, 180, 270]) {
for (const direction of directions) {
const node = new TextCardElement({ x: 100, y: 200 }).toNode();
node.data.rotation = rotation;
// hx/hy 表示当前 handle 的局部符号;位移先旋转到世界,再比较缩放前后相反侧锚点的世界位置。
const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
const delta = rotateVector({ x: hx * 60, y: hy * 40 }, rotation);
@@ -49,6 +58,7 @@ test("all eight handles resize cards while retaining the opposite local anchor,
}
});
// 纯文本宽高独立改变,最小值受限但不改变字号;仅拉宽或拉高不应连带另一维度。
test("plain text width and height resize independently without changing the font", () => {
const node = new PlainTextElement({ x: 0, y: 0 }).toNode();
const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 });
@@ -61,6 +71,7 @@ test("plain text width and height resize independently without changing the font
assert(min.width >= 60); assert(min.height >= 24); assert.equal(min.fontSize, undefined);
});
// 旋转跟随指针绕中心方向,Shift 风格吸附到 15 度,负方向归一为 0..360 范围。
test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => {
const node = new TextCardElement({ x: 0, y: 0 }).toNode();
const center = nodeCenter(node);
@@ -71,6 +82,7 @@ test("rotation follows the pointer, supports 15-degree snapping and normalizes t
close(rotationFromPointer(node, start, { x: center.x, y: center.y - 100 }), 270);
});
// checkpoint 内变换可保存恢复并一次 undo;纯文本 type、宽高、旋转和原字号各自保持正确。
test("geometry and plain-text type survive save/restore and transforms undo as one gesture", () => {
const node = new PlainTextElement({ x: 20, y: 30 }).toNode();
let state = { past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] };
@@ -84,6 +96,7 @@ test("geometry and plain-text type survive save/restore and transforms undo as o
close(undo.present.nodes[0].data.rotation, 0);
});
// 旧 material/text 草稿仍解码成卡片且补默认 rotation;负尺寸、超界角度及超大字号逐项拒绝。
test("old text-card drafts remain cards and invalid transform values are rejected", () => {
const old = { id: "old", type: "material", position: { x: 0, y: 0 }, data: { kind: "text", title: "Old", text: "Keep" } };
const document = { ...emptyCanvas(), nodes: [old] };
@@ -94,6 +107,7 @@ test("old text-card drafts remain cards and invalid transform values are rejecte
}
});
// 对 0/90/180 度检查四个边中心和单位法线,再验证缩放后的实际 right/bottom 锚点。
test("connection endpoints stay on the exact border centers after resize and rotation", () => {
const node = new TextCardElement({ x: 20, y: 30 }).toNode();
const expected = {
@@ -115,6 +129,7 @@ test("connection endpoints stay on the exact border centers after resize and rot
assert.deepEqual(nodeConnector(resized, "bottom").point, { x: 200, y: 280 });
});
// Bezier 字符串必须从精确 source anchor 开始到 target anchor 结束;selected 仅改变 UI,不改变几何路径。
test("Bezier paths include both exact card anchors, independent of selected handle sizes", () => {
const source = new TextCardElement({ x: 20, y: 30 }).toNode();
const target = new TextCardElement({ x: 500, y: 180 }).toNode();
+13
View File
@@ -1,3 +1,7 @@
/**
* 本地图标目录/分片和共享 ResourceLoader 测试。直接读仓库生成资产,校验本地哈希和引用,不访问图标 CDN。
* loader 的活跃并发与缓存容量受限,不能据此推断待处理队列本身有长度上限。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
@@ -8,10 +12,12 @@ import { iconAsset, validateIconShard } from "../src/lib/canvas-icon-loader.ts";
import { decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { createResourceLoader } from "../src/lib/resource-loader.ts";
// 核对目录数量、ID 唯一性、分片文件哈希/体积/合法 SVG 数据、每个图标和分类存在;选取三个 ID 再验证旧快照往返。
test("all Lucide and experimental Tabler IDs map to valid local shards and survive snapshots", async () => {
assert.equal(catalog.filter((icon) => icon.source === "lucide").length, 2313);
assert.equal(catalog.filter((icon) => icon.source === "tabler").length, 24);
assert.equal(new Set(catalog.map((icon) => icon.name)).size, catalog.length);
// 按 manifest 逐分片读取本地 JSON;文件名哈希取 trimEnd 后文本的 SHA-256 前 12 位,单片小于 20000 字节。
const loaded = new Map();
for (const asset of Object.values(manifest.assets)) {
const content = await readFile(`public${manifest.root}/${asset}`, "utf8");
@@ -30,13 +36,16 @@ test("all Lucide and experimental Tabler IDs map to valid local shards and survi
}
});
// 对 script、onload、外部 paint URL 和 use/href 的样例逐个拒绝;这是数据校验例,不是完整浏览器 SVG 安全审计。
test("icon data rejects executable shapes, events and external paint references", () => {
for (const node of [["script", {}], ["path", { onload: "alert(1)" }], ["path", { fill: "url(https://example.com)" }], ["use", { href: "bad" }]]) {
assert.throws(() => validateIconShard({ Star: [node] }));
}
});
// 让 setImmediate 推进到调度任务阶段;用于观察尚未开始、排队及已释放容量的加载。
const tick = () => new Promise((resolve) => setImmediate(resolve));
// request 后立即 abort,尚未进入调度时不能启动网络加载,也不能把未完成值留入缓存。
test("an immediately unmounted icon does not start a network request", async () => {
let calls = 0;
const loader = createResourceLoader(async () => { calls++; return "unused"; });
@@ -47,8 +56,10 @@ test("an immediately unmounted icon does not start a network request", async ()
assert.equal(calls, 0); assert.equal(loader.peek("unused"), undefined);
});
// 同 key a 两个订阅只加载一次,容量二时 c 等待;完成后缓存命中不新增调用,峰值为二。
test("loader bounds concurrency, deduplicates shared shards and reuses cache", async () => {
let active = 0, peak = 0, calls = 0;
// 手动完成 active 加载,用于在容量为二时观察第三项等待及共享 key 去重。
const finish = [];
const loader = createResourceLoader(async (key) => {
active++; peak = Math.max(peak, active); calls++;
@@ -62,6 +73,7 @@ test("loader bounds concurrency, deduplicates shared shards and reuses cache", a
assert.equal(await loader.request("a"), "a"); assert.equal(calls, 3); assert.equal(peak, 2);
});
// 取消排队 b 后它永不启动;取消 a 的一个订阅不能中止另一个订阅共享的 active a。
test("unused queued loads never start; a remaining subscriber keeps a shared load alive", async () => {
const seen = []; let release;
const loader = createResourceLoader(async (key) => { seen.push(key); if (key === "a") await new Promise((resolve) => release = resolve); return key; }, 1);
@@ -73,6 +85,7 @@ test("unused queued loads never start; a remaining subscriber keeps a shared loa
await rejected; assert.equal(await shared, "a"); await tick(); assert.deepEqual(seen, ["a"]);
});
// active abort 后释放容量,失败 key 可重新加载;容量二的 LRU 在加入 a/b 后淘汰更早 fail,保留仍在缓存的 a。
test("active aborts and failed requests can retry; least recently used cache is bounded", async () => {
let fail = true;
const loader = createResourceLoader(async (key, signal) => {
+7
View File
@@ -1,3 +1,7 @@
/**
* 旧画布 CanvasElement 类体系的纯图片能力及图标搜索测试,与 headless component registry 的模型定义分开。
* 只操作节点数据、几何和序列化,不挂载 renderer 或读取图片字节。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
@@ -5,6 +9,7 @@ import { CanvasElement, CardElement, PlainImageElement } from "../src/lib/canvas
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
import { iconCatalog, iconsById, searchIcons } from "../src/components/canvas/icon-catalog.ts";
// PlainImageElement 继承通用变换但不继承 CardElement,比例缩放、名称和 src 可恢复,重命名可撤销。
test("plain images inherit transforms without card connections and preserve source/name on restore", () => {
const image = new PlainImageElement({ x: 0, y: 0 }, "", "data:image/png;base64,YQ==");
assert(image instanceof CanvasElement); assert.equal(image instanceof CardElement, false);
@@ -18,6 +23,7 @@ test("plain images inherit transforms without card connections and preserve sour
assert.equal(canvasReducer(renamed, { type: "undo" }).present.nodes[0].data.title, "图片");
});
// 纯图片不能作为卡片连线目标;删除连线后 javascript: src 仍必须被解码器拒绝。
test("plain image sources are required and pure images cannot acquire card connections", () => {
const image = makeCanvasNode("plain-image", { x: 0, y: 0 }, "data:image/png;base64,YQ==");
const card = makeCanvasNode("text", { x: 300, y: 0 });
@@ -27,6 +33,7 @@ test("plain image sources are required and pure images cannot acquire card conne
assert.throws(() => decodeCanvas(JSON.stringify(document)));
});
// 图标 ID 唯一并符合后端字符形状,中文类别与英文词可搜索;仅验证当前本地目录,不请求后端搜索。
test("the complete Lucide catalog has unique backend-compatible identifiers and searchable names", () => {
assert(iconCatalog.length > 2000);
assert.equal(iconsById.size, iconCatalog.length);
+31
View File
@@ -1,21 +1,32 @@
/**
* 旧 linked-board 成员迁移的 hook 与 DOM 场景,由 canvas-ui-check 提供运行环境。
* 内存 records/receipt 模拟版本、丢响应和能力探测;它验证客户端协调,不能替代后端数据库原子性或持久恢复测试。
*/
import assert from 'node:assert/strict';
// 先直接驱动 useCanvasTree 精确控制竞态,再以 GraphCanvas 的拖放事件检查局部状态和其他节点可编辑。
export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasNode,emptyCanvas,tick,mock,GraphCanvas,event,window}) {
// 父画布拥有分区入口和其他文本,子画布拥有待迁移图标;投影 ID 与服务端本地 ID 区分。
const parent='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',child='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='frame';frame.data.subgraph={boardId:child};
const icon=makeCanvasNode('icon',{x:10,y:20});icon.id='moving';
const other=makeCanvasNode('text',{x:950,y:100});other.id='other';
let records,tree,posts,saves,release,mode,receipt,key,body,capability=true,capMode="",capCalls=0;
// 统一生成 JSON 响应,HTTP 错误也由客户端常规解析路径处理。
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
// 只挂载 hook 并暴露最新 tree,测试直接调用命令而不依赖 DOM 手势。
function Fixture(){const seed=structuredClone(records.get(parent));tree=useCanvasTree(parent,'w',seed.document,seed);return null;}
// 卸载后重建父子快照与请求记录;模式选择控制能力慢响应、失败、版本冲突、丢 ACK 或挂起提交。
const reset=async(nextMode='success')=>{
await act(async()=>{root.render(null);await tick();});localStorage.clear();
records=new Map([[parent,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(frame),structuredClone(other)]}}],[child,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(icon)]}}]]);
posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null;capMode=nextMode;capCalls=0;
if(mode==='queued'){const second=structuredClone(icon);second.id='moving-two';second.position={x:100,y:20};records.get(child).document.nodes.push(second);mode='delayed';}
// 内存 API 按路径分流:完整快照保存、版本命令、分区创建与成员迁移,各自记录参与者版本。
mock(async(url,options={})=>{
const parts=new URL(url,'http://localhost').pathname.split('/'),id=parts[6],suffix=parts.slice(8).join('/'),method=options.method||'GET';
if(suffix==='ai/runs')return json({runs:[]});
// 慢响应延迟超过两秒;一次网络失败只影响首个请求,允许检查重新探测而非永久判为不支持。
if(suffix==='capabilities'){
capCalls++;if(capMode==='slowcap'){await new Promise(r=>setTimeout(r,2100));if(options.signal?.aborted)throw new DOMException('Aborted','AbortError');}
if(capMode==='cap-failure'&&capCalls===1)throw new TypeError('network unavailable');
@@ -27,6 +38,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
const document=JSON.parse(options.body).document;saves.push({id,document});records.set(id,{version:records.get(id).version+1,document});return json({version:records.get(id).version});
}
if(suffix==='ai/runs'&&method==='POST'){const next=structuredClone(records.get(id));next.version++;records.set(id,next);return json({canvas_version:next.version,board_id:id});}
// 模拟创建父入口和新子图,可在状态已写入后暂缓返回,制造上传完成先于命令响应的窗口。
if(suffix==='subgraphs'&&method==='POST'){
const input=JSON.parse(options.body).frame,next=structuredClone(records.get(parent)),childID='66666666-6666-4666-8666-666666666666';
const added=makeCanvasNode('frame',input.position);added.id=input.id;added.data.subgraph={boardId:childID};next.document.nodes.push(added);next.version++;records.set(parent,next);
@@ -34,29 +46,36 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
if(mode==='create-delayed')await new Promise(resolve=>{release=resolve;});
return json({transaction_id:crypto.randomUUID(),parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],child:{board_id:childID,...empty}});
}
// 保存原 body/key;命中上一次回执必须逐字节相同,返回既有结果而不再移动成员。
assert.equal(suffix,'subgraphs/frame/members');posts.push({body:options.body,key:options.headers['Idempotency-Key']});
if(receipt && options.headers['Idempotency-Key']===key){assert.equal(options.body,body);return json(receipt);}
receipt=null;
body=options.body;key=options.headers['Idempotency-Key'];const input=JSON.parse(body);
// 先核对双方期望版本,412/409 模式在迁移前返回;成功路径转换 owner 局部坐标并同时更新两个记录。
for(const id of [parent,child])assert.equal(input.expected_versions[id],records.get(id).version);
if(mode==='conflict'){records.get(parent).version++;mode='success';return json({error:'version_conflict'},412);}
if(mode==='rejected'){mode='success';return json({error:'ownership_conflict'},409);}
const source=structuredClone(records.get(input.source_board_id)),target=structuredClone(records.get(input.target_board_id));
const moving=source.document.nodes.find(n=>n.id===input.node_ids[0]);const pos=input.positions?.[moving.id]||moving.position;
// 本夹具中分区内容原点相对父画布为 (124,136),进出分区据此增减坐标。
const converted=input.source_board_id===child?{x:pos.x+124,y:pos.y+136}:{x:pos.x-124,y:pos.y-136};
source.document.nodes=source.document.nodes.filter(n=>n.id!==moving.id);target.document.nodes.push({...moving,position:converted});source.version++;target.version++;records.set(input.source_board_id,source);records.set(input.target_board_id,target);
receipt={transaction_id:crypto.randomUUID(),snapshots:[...records].map(([board_id,value])=>({board_id,...structuredClone(value)}))};
// lost 在记录已更新后回 502;delayed 则挂起成功 ACK,分别覆盖不确定重试与并行本地编辑。
if(mode==='lost')return json({error:'upstream_unavailable'},502);
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
return json(receipt);
});
await act(async()=>{root.render(h(Fixture));await tick();await tick();});
};
// 有限轮询并推进 React effect,超时描述具体等待条件,不把请求已发出当作已确认。
const wait=async(check,label)=>{for(let i=0;i<120;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);};
// 选中投影图标并模拟拖到父坐标,再提交跨 owner 迁移,等待请求实际进入替身。
const start=async()=>{
await act(async()=>{tree.select(child+'::moving');tree.setDragging(true);tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:950,y:200},dragging:false}]);tree.setDragging(false);void tree.moveMembers(parent,'frame',child,parent,['moving'],[parent,child]);await tick();});
await wait(()=>posts.length,'move admission');
};
// 迁移携带落点,不先 PATCH;挂起期间只保护待移动成员,其他文本/拖动仍可继续。
await reset('delayed');await start();
assert.equal(saves.length,0,'Drop-only atomic moves must not PATCH first');assert.equal(tree.busy,false,'Member moves must not lock all editing');assert.equal(tree.memberJobs.length,1);
assert.deepEqual(JSON.parse(posts[0].body).positions.moving,{x:826,y:64});
@@ -64,6 +83,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑');
await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:1200,y:900},dragging:false}]);await tick();});
assert.deepEqual(tree.current.current.get(child).nodes[0].position,{x:826,y:64},'Pending member cannot move again');
// 放行 ACK 后检查坐标、选中和唯一归属,同时保留并最终保存无关编辑;其 undo 不能复活旧 owner。
await act(async()=>{release();await tick();await tick();});await wait(()=>!tree.memberJobs.length,'move acknowledged');
assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑','Remote snapshots preserve local text');
assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130},'Remote snapshots preserve unrelated dragging');
@@ -76,13 +96,16 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
await act(async()=>{await tree.redo();await tick();});
assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130});
console.log('PASS member sync: one POST, no preceding PATCH, local editing during delayed commit, geometry and draft merge');
// 丢 ACK 的重试沿用完全相同的 body/key,并保留响应丢失之后的新编辑。
await reset('lost');await start();await wait(()=>tree.memberJobs[0]?.error,'uncertain result');
assert.equal(tree.busy,false);await act(async()=>{tree.nodeAction('other',id=>({type:'edit',id,text:'响应丢失后的编辑'}));tree.retryMembers();await tick();});
await wait(()=>!tree.memberJobs.length,'idempotent retry');assert.equal(posts.length,2);assert.equal(posts[0].body,posts[1].body);assert.equal(posts[0].key,posts[1].key);assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'响应丢失后的编辑');
console.log('PASS member retry: lost acknowledgement reuses body/key, keeps edits and does not duplicate ownership');
// 明确拒绝后读取新版本再构造新命令,因此新幂等键与旧键不同。
await reset('conflict');await start();await wait(()=>tree.memberJobs[0]?.error,'version rejection');
await act(async()=>{tree.retryMembers();await tick();});await wait(()=>!tree.memberJobs.length,'fresh version retry');assert.equal(posts.length,2);assert.notEqual(posts[0].key,posts[1].key);assert.equal(JSON.parse(posts[1].body).expected_versions[parent],2);
console.log('PASS member conflict: definite zero-write rejection reloads versions before a fresh command');
// 两个迁移共享参与者,第二个等待首个 ACK 后的新版本;不额外发送坐标 PATCH。
await reset('queued');await start();
await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving-two',position:{x:1000,y:300},dragging:false}]);void tree.moveMembers(parent,'frame',child,parent,['moving-two'],[parent,child]);await tick();});
assert.equal(posts.length,1,'Second ownership change waits for participant versions');
@@ -91,6 +114,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.equal(saves.length,0);assert.equal(tree.rootDocument.nodes.filter(n=>n.id.startsWith('moving')).length,2);assert.equal(tree.current.current.get(child).nodes.length,0);
console.log('PASS member queue: unrelated cross-frame drops serialize versions without extra coordinate PATCH');
await reset();
// 未上传图片尚无服务端节点:本地切换归属可撤销;上传到当前 owner 后,后续 undo/redo 才使用服务端迁移。
const pendingImage=makeCanvasNode('image',{x:200,y:200},'blob:editable-upload');pendingImage.id='pending-image';pendingImage.pendingUpload=pendingImage.id;
await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:pendingImage}]});tree.movePendingImage(parent,child,pendingImage.id);await tick();});
assert.equal(posts.length,0);assert.equal(saves.length,0);assert.deepEqual(tree.current.current.get(child).nodes.find(n=>n.id===pendingImage.id).position,{x:76,y:64});
@@ -102,6 +126,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
await act(async()=>{await tree.undo();await tick();});assert.ok(tree.rootDocument.nodes.some(n=>n.id===pendingImage.id));assert.ok(!tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));
await act(async()=>{await tree.redo();await tick();});assert.ok(tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));assert.equal(posts.length,2,'Undo/redo after persistence use real ownership transactions');
console.log('PASS pending image ownership: editable local moves, atomic local undo, current-owner upload resolution and transactional undo after persistence');
// 版本刷新和分区创建响应都不能覆盖本地 pending/已完成图片;重复父快照也只能采用一次。
await reset('create-delayed');
const asyncImage=makeCanvasNode('image',{x:800,y:500},'blob:async-upload');asyncImage.id='async-image';asyncImage.pendingUpload=asyncImage.id;
await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:asyncImage}]});await tick();});
@@ -112,13 +137,17 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
await act(async()=>{tree.resolveImageUpload(asyncImage.id,{id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777'});release();await creation;await tick();});
assert.ok(tree.rootDocument.nodes.some(n=>n.id===asyncImage.id&&!n.pendingUpload),'Concurrent command response cannot overwrite the resolved image');assert.equal(tree.rootDocument.nodes.filter(n=>n.id==='new-frame').length,1,'Repeated parent snapshots are adopted only once');
console.log('PASS upload command races: version-only refresh and duplicate transaction snapshots retain pending/ready local images');
// 慢能力探测和首次探测失败都不能误触发旧协议回退,失败后需重新探测。
await reset('slowcap');await start();await wait(()=>!tree.memberJobs.length,'slow capability move');assert.equal(saves.length,0,'A slow capability response must not trigger legacy PATCH fallback');
await reset('cap-failure');await start();await wait(()=>!tree.memberJobs.length,'capability retry');assert.equal(capCalls,2);assert.equal(saves.length,0,'A failed prefetch is retried rather than cached as unsupported');
console.log('PASS member capabilities: responses slower than two seconds and transient prefetch errors retain atomic moves');
// 明确不支持 positions 时才走旧的坐标保存加迁移两请求路径。
capability=false;await reset();await start();await wait(()=>!tree.memberJobs.length,'legacy move');assert.equal(saves.length,1,'Old backend retains the coordinate save');assert.equal(JSON.parse(posts[0].body).positions,undefined);
console.log('PASS member compatibility: capability-gated positions and legacy two-request fallback');
// 切到真实 GraphCanvas DOM 事件:拖出子图显示局部同步徽标,另一个节点仍可拖动且 ACK 不复位它。
capability=true;await reset('delayed');
await act(async()=>{root.render(null);await tick();root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Member gestures',version:1},snapshot:structuredClone(records.get(parent))}));await tick();await tick();});
// 每次按投影 ID 重新查子图图标;成功迁移后应以父画布本地 ID 出现。
const member=()=>document.querySelector(`.react-flow__node[data-id="${child}::moving"]`);
await wait(()=>member(),'expanded icon');
await event(member(),'mousedown',{clientX:150,clientY:160,button:0,buttons:1});
@@ -127,6 +156,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.ok(document.querySelector('[aria-label="元素同步状态"]'),'Moving node has a local sync badge');
assert.equal([...document.querySelectorAll('[role="status"]')].some(n=>n.textContent==='正在同步画布…'),false,'No full-canvas synchronization overlay');
assert.equal(member().classList.contains('draggable'),false,'Only pending nodes and frame geometry are protected');
// 记录无关节点变换,确认同步中实际拖动生效,随后与 ACK 后 DOM 比较。
const otherNode=()=>document.querySelector('.react-flow__node[data-id="other"]');assert.equal(otherNode().classList.contains('draggable'),true);
const originalTransform=otherNode().style.transform;
await event(otherNode(),'mousedown',{clientX:1000,clientY:150,button:0,buttons:1});await event(window,'mousemove',{clientX:1010,clientY:150,buttons:1});await event(window,'mousemove',{clientX:1100,clientY:180,buttons:1});await event(window,'mouseup',{clientX:1100,clientY:180,button:0});
@@ -136,5 +166,6 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.equal(otherNode().style.transform,changedTransform,'Acknowledgement does not reset concurrent dragging');
assert.ok(document.querySelector('.react-flow__node[data-id="moving"]'),'Moved node has exactly one new owner');
console.log('PASS member UI: real cross-frame icon gesture, local status, no overlay and concurrent unrelated drag');
// 正常结束卸载 hook/画布;整个 JSDOM 与全局资源由外层 canvas-ui-check 清理。
await act(async()=>{root.render(null);await tick();});
}
+10
View File
@@ -1,9 +1,15 @@
/**
* 响应头、body 总传输与空闲三类截止时间的组合测试。用 Response/ReadableStream 和 fetch 替身验证读取、取消与诊断。
* HTTP 状态 200 不代表 body 可用:传输中断、非法 JSON、后端错误和 204 在 api 层需要分别表达。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { fetchCanvasResponse } from '../src/lib/canvas-response.ts';
import { api, ApiError } from '../src/lib/api.ts';
// 延迟第二个真实流块,使传输超过 header 截止但仍在 body/idle 截止内;不是网络服务计时。
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
// header 返回后取消其截止约束,两个 UTF-8 块继续读完;ETag、完整字节数、chunk 数和 transfer_ms 报告均要保留。
test('snapshot body outlives the header deadline and reports transfer timing and bytes',async()=>{
const reports=[]; const bytes=new TextEncoder().encode('{"version":210,"document":{"title":"测试"}}');
const upstream=new Response(new ReadableStream({async start(c){ c.enqueue(bytes.slice(0,10));await wait(50);c.enqueue(bytes.slice(10));c.close();}}),{headers:{ETag:'"210"'}});
@@ -11,15 +17,18 @@ test('snapshot body outlives the header deadline and reports transfer timing and
assert.equal(response.headers.get('etag'),'"210"');assert.equal((await response.json()).version,210);
assert.equal(reports.length,1);assert.equal(reports[0].outcome,'complete');assert.equal(reports[0].bytes,bytes.length);assert.ok(reports[0].transfer_ms>=30);assert.equal(reports[0].chunks,2);
});
// 首块后不再产生数据,触发 idle 超时;consumer 必须收到 TimeoutError,上游 cancel 被调用,不能返回空快照。
test('snapshot idle timeout aborts unfinished bodies instead of returning a valid empty snapshot',async()=>{
const reports=[];let cancelled=false;
const upstream=new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));},cancel(){cancelled=true;}}));
const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:15},async()=>upstream,r=>reports.push(r));
await assert.rejects(response.json(),{name:'TimeoutError'});assert.ok(cancelled);assert.equal(reports[0].outcome,'error');assert.equal(reports[0].error,'TimeoutError');
});
// fetch 尚未返回头时监听 AbortSignal,header 超时应以 TimeoutError 拒绝,而不是等待 body 读取。
test('header deadline rejects a server that has not responded',async()=>{
await assert.rejects(fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:100},(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason)))),{name:'TimeoutError'});
});
// 调用方 abort 和 consumer 直接 cancel 两条路径都释放上游读取,分别核对 AbortError 和 cancel 总次数。
test('disconnect and consumer cancellation release the upstream read',async()=>{
const abort=new AbortController();let cancelled=0;
const make=()=>new Response(new ReadableStream({cancel(){cancelled++;}}));
@@ -27,6 +36,7 @@ test('disconnect and consumer cancellation release the upstream read',async()=>{
abort.abort();await assert.rejects(response.text(),{name:'AbortError'});
const second=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:100},async()=>make());await second.body.cancel();assert.equal(cancelled,2);
});
// 临时替换全局 fetch,检查 interrupted/invalid JSON/server error/null 四种结果;finally 恢复原 fetch,避免污染其他用例。
test('client distinguishes a failed 200 body transfer, malformed JSON, server errors and 204',async()=>{
const original=globalThis.fetch;
try {
+9
View File
@@ -1,11 +1,16 @@
/**
* 旧快照画布的分支创建、文字样式、图标变换与恢复校验。这里只验证 legacy reducer/schema,不代表 headless 增量事务实现。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts";
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
// 每次返回一个新文本节点,避免样式/分支变更在不同测试之间共享可变实例。
function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; }
// 从同一父节点分别创建 text/script/image,检查连线端口、图片源、快照往返,再用一次 replace 历史整体撤销。
test("branch type selection creates the chosen text, script or uploaded image and keeps the link in one undo step", () => {
for (const kind of ["text", "script", "image"]) {
const original = seeded();
@@ -22,6 +27,7 @@ test("branch type selection creates the chosen text, script or uploaded image an
}
});
// 连线释放在任意画布坐标时,新 script 直接落在该 release 点,并只增加一条边。
test("a connection released on the canvas creates the chosen node at the release point", () => {
const original = seeded();
const at = { x: -300, y: 700 };
@@ -30,6 +36,7 @@ test("a connection released on the canvas creates the chosen node at the release
assert.equal(result.edges.length, 1);
});
// 颜色/背景/字体/行高等样式与字号可保存和遍历历史;部分样式清除只改目标键,保留未涉及颜色。
test("all text styles and font size survive serialization, undo and redo", () => {
const original = seeded();
const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 };
@@ -45,6 +52,7 @@ test("all text styles and font size survive serialization, undo and redo", () =>
assert.equal(decodeCanvas(JSON.stringify(cleared.present)).nodes[0].data.textStyle.bold, false);
});
// 逐个非法样式/类型和可执行样式片段均拒绝;非法 icon ID 也不能通过旧快照恢复。
test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a restored canvas", () => {
for (const style of [{ color: "url(https://example.com)" }, { background: "red" }, { fontFamily: "custom" }, { lineHeight: Infinity }, { bold: "true" }, { align: "justify" }, { align: ["left"] }, { fontFamily: ["sans"] }, { position: "fixed" }, []]) {
const doc = seeded(); doc.nodes[0].data.textStyle = style;
@@ -55,6 +63,7 @@ test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a res
assert.throws(() => decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })), /图标/);
});
// IconElement 继承通用变换而非 CardElement,ID、颜色、旋转可恢复;等比缩放有最小尺寸且不能分支。
test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => {
const element = new IconElement({ x: 10, y: 20 });
assert(element instanceof CanvasElement);
+18
View File
@@ -1,12 +1,20 @@
/**
* 旧 CanvasSync 完整快照自动保存及旧加载/草稿恢复测试。正在发送的请求串行,排队文档只保留最新份。
* 显式 retry 可以发送最新文档,与 headless 冻结请求的未知结果查询/原字节重试是不同契约,不可相互替代。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts";
import { loadBoardCanvas } from "../src/lib/canvas-loader.ts";
// 返回固定 text ID 的新快照,便于只改变内容而不被随机 ID 差异触发多余保存。
function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; }
// 手动完成首个请求以检查在途期间版本和队列合并,reject 也供故障场景使用。
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
// setImmediate 让保存泵进入 request,再添加后续文档,避免只测同一同步调用窗口。
const tick = () => new Promise((resolve) => setImmediate(resolve));
// A 请求挂起时排队 B/C 不并发发送;A ACK=2 后只发送 C,并使用连续 If-Match 1→2。
test("serialized autosaves use successive versions and coalesce queued documents", async () => {
const calls = [], first = deferred();
const writer = new CanvasSync(async (path, options) => {
@@ -23,6 +31,7 @@ test("serialized autosaves use successive versions and coalesce queued documents
assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "C"); assert.equal(writer.version, 3);
});
// 首次 version_conflict 停止自动写,后续 B 不再发送,旧服务版本和错误保留。
test("version conflicts stop automatic writes and preserve the latest pending content", async () => {
const calls = [];
const writer = new CanvasSync(async (_path, options) => { calls.push(options); throw new Error("version_conflict"); }, "canvas", { version: 7, document: emptyCanvas() }, () => {});
@@ -30,6 +39,7 @@ test("version conflicts stop automatic writes and preserve the latest pending co
assert.equal(calls.length, 1); assert.equal(writer.error, "version_conflict"); assert.equal(writer.version, 7);
});
// 故障后手动 retry 发送当前最新 B,成功清错误;没有承诺保留最初 A 的冻结字节。
test("explicit retry sends the newest document after a failed write", async () => {
const calls = [];
const writer = new CanvasSync(async (_path, options) => { calls.push(options); if (calls.length === 1) throw new Error("upstream_unavailable"); return { version: 2 }; }, "canvas", { version: 1, document: emptyCanvas() }, () => {});
@@ -37,6 +47,7 @@ test("explicit retry sends the newest document after a failed write", async () =
assert.equal(calls.length, 2); assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "B"); assert.equal(writer.error, "");
});
// selected/measured 的变更不产生保存,payload 去 UI 字段;真正内容变化才调用一次接口。
test("selection and measured dimensions do not trigger redundant writes or leak UI fields", async () => {
const initial = document("keep"); let calls = 0;
const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: initial }, () => {});
@@ -48,6 +59,7 @@ test("selection and measured dimensions do not trigger redundant writes or leak
assert.equal(calls, 1);
});
// workspace/board 的 ID 与 slug 四组合都得到 canonical board 身份及保存快照,路径仍按传入引用构造。
test("all ID/slug combinations load the same canonical board and snapshot", async () => {
for (const workspace of ["space-id", "creative-space"]) for (const board of ["board-id", "my-board"]) {
const calls = [];
@@ -57,11 +69,13 @@ test("all ID/slug combinations load the same canonical board and snapshot", asyn
}
});
// 快照读取失败或 board 身份不符直接拒绝,不以空文档允许用户覆盖远端。
test("failed snapshot loads and mismatched boards prevent editing rather than creating an empty replacement", async () => {
await assert.rejects(loadBoardCanvas(async (path) => { if (path.endsWith("/canvas")) throw new Error("upstream_unavailable"); return { id: "board", workspace_id: "space", kind: "board" }; }, "space", "board"), /upstream/);
await assert.rejects(loadBoardCanvas(async (path) => path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "other", workspace_id: "space", kind: "board" }, "space", "board"), /invalid_reference/);
});
// dirty 及旧草稿可提供显式恢复候选;clean 备份或与远端相同内容返回 null,不能静默覆盖服务。
test("dirty local recovery and legacy drafts are offered for explicit migration; clean backups never replace server data", () => {
const remote = { version: 3, document: document("server") };
const local = document("local");
@@ -72,6 +86,7 @@ test("dirty local recovery and legacy drafts are offered for explicit migration;
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: remote.document, dirty: true }) }), "key", remote), null);
});
// 指纹按内容规范化键序,textStyle 属性顺序不同不触发保存,也不被当作未保存草稿差异。
test("canonical fingerprints ignore server JSON key ordering", async () => {
const remote = document("same"), local = document("same");
remote.nodes[0].data.textStyle = { bold: true, color: "#123456" };
@@ -85,6 +100,7 @@ test("canonical fingerprints ignore server JSON key ordering", async () => {
});
// 先挂起两种读取再检查 started=2,metadata/snapshot 必须并发启动。
test("board metadata and snapshot begin concurrently without a request waterfall", async () => {
const started = []; const releases = [];
const loading = loadBoardCanvas((path) => { started.push(path); return new Promise((resolve) => releases.push(() => resolve(path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "board", workspace_id: "space", kind: "board" }))); }, "space", "board");
@@ -92,6 +108,7 @@ test("board metadata and snapshot begin concurrently without a request waterfall
});
// 微小 viewport 浮点误差保持同一 state,正常 viewport 也不单独保存;随后内容变化才写一次。
test("viewport restoration roundoff does not create an autosave", async () => {
const doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
const state={past:[],present:doc,future:[]};
@@ -104,6 +121,7 @@ test("viewport restoration roundoff does not create an autosave", async () => {
});
// pendingUpload/Blob 节点和触边只在本地可编辑;传输排除它们,确认后的新 src、最新坐标和连线才一起进入快照。
test("editable local uploads and touching edges never enter transport snapshots",async()=>{
const pending={...makeCanvasNode("image",{x:30,y:40},"blob:local-file"),pendingUpload:"upload-one"};pending.id="image-local";
const initial=emptyCanvas(),calls=[];
+35
View File
@@ -1,3 +1,8 @@
/**
* 旧画布 JSDOM 回归入口:临时 esbuild 打包实际 React 组件,再注入 DOM/布局/网络替身执行场景。
* 运行会编译临时 fixture;不启动浏览器或开发服务器。模拟尺寸、命中和指针捕获不能替代浏览器布局/手势验证。
* CANVAS_UI_FEEDBACK_ONLY 仅跑反馈;ROTATION_BASELINE 对打包输入移除旧点击阻止逻辑,用于复现旋转取消。
*/
// Isolated DOM regression check; no browser or dev server is used.
// npm install --prefix /tmp/canvas-ui-tools --ignore-scripts esbuild jsdom
// CANVAS_UI_TOOLS=/tmp/canvas-ui-tools node tests/canvas-ui-check.mjs
@@ -7,37 +12,46 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
// 测试工具从 CANVAS_UI_TOOLS 指定目录解析,业务 React/组件仍来自当前仓库依赖。
const tools = createRequire(resolve(process.env.CANVAS_UI_TOOLS || "/tmp/canvas-ui-tools", "package.json"));
const { JSDOM } = tools("jsdom");
const { build, stop } = tools("esbuild");
// 独立 DOM 和 localhost origin 提供 localStorage 等环境,不请求 localhost 服务。
const dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', { url: "http://localhost", pretendToBeVisual: true });
const { window } = dom;
// React's async act creates MessageChannels; track and close test-owned ports.
const NativeMessageChannel = globalThis.MessageChannel;
const channels = [];
globalThis.MessageChannel = class extends NativeMessageChannel { constructor() { super(); channels.push(this); } };
// 把组件依赖的 DOM API 暴露到 Node 全局;这是独立测试进程,finally 不逐项恢复这些全局。
for (const key of ["window", "document", "HTMLElement", "Element", "Node", "SVGElement", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "MutationObserver", "localStorage", "getComputedStyle"]) Object.defineProperty(globalThis, key, { value: key === "window" ? window : typeof window[key] === "function" && key === "getComputedStyle" ? window[key].bind(window) : window[key], configurable: true });
Object.defineProperty(globalThis, "navigator", { value: window.navigator, configurable: true });
globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window);
globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window);
// 媒体查询、矩阵和 React act 环境使用最小替身,仅满足组件读取的字段。
window.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} });
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
globalThis.DOMMatrixReadOnly = window.DOMMatrixReadOnly = class { m22 = 1; };
// 把 MouseEvent 扩为最小 pointer 事件并固定鼠标类型;不是原生多指输入实现。
class PointerEvent extends window.MouseEvent { constructor(type, init = {}) { super(type, init); this.pointerId = init.pointerId || 1; this.isPrimary = true; this.pointerType = "mouse"; } }
globalThis.PointerEvent = window.PointerEvent = PointerEvent;
// WeakMap 记录元素持有的 pointerId,仅模拟捕获 API 状态,不会自动重定向后续事件。
const captures = new WeakMap();
window.HTMLElement.prototype.setPointerCapture = function (id) { captures.set(this, id); };
window.HTMLElement.prototype.hasPointerCapture = function (id) { return captures.get(this) === id; };
window.HTMLElement.prototype.releasePointerCapture = function () { captures.delete(this); };
// 优先用内联宽高,其余给确定默认值;所有矩形起点为零,不进行 CSS 排版计算。
const width = (element) => parseFloat(element.style.width) || (element.className?.includes?.("react-flow__node") ? 260 : 1200);
const height = (element) => parseFloat(element.style.height) || (element.getAttribute("aria-label") === "图标列表" ? 192 : element.className?.includes?.("react-flow__node") ? 200 : 800);
for (const [key, get] of [["offsetWidth", width], ["offsetHeight", height], ["clientWidth", width], ["clientHeight", height]]) Object.defineProperty(window.HTMLElement.prototype, key, { get() { return get(this); } });
window.HTMLElement.prototype.getBoundingClientRect = function () { return { x: 0, y: 0, left: 0, top: 0, width: width(this), height: height(this), right: width(this), bottom: height(this) }; };
// observe 立即同步回调尺寸,unobserve/disconnect 无行为;不验证真实观察器调度顺序。
globalThis.ResizeObserver = window.ResizeObserver = class {
constructor(callback) { this.callback = callback; }
observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); }
unobserve() {} disconnect() {}
};
// 场景可替换 Foundry 请求处理器;图标请求记录后从本地 public 读 JSON,拒绝其他网络目标。
let apiMock = null;
const requests = [];
globalThis.fetch = async (url, options) => {
@@ -47,26 +61,33 @@ globalThis.fetch = async (url, options) => {
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
return new Response(await readFile(resolve("public", String(url).slice(1))), { headers: { "Content-Type": "application/json" } });
};
// 临时目录只存打包 fixture,finally 删除;不把生成文件写入项目源码。
const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-"));
try {
const outfile = resolve(folder, "fixture.cjs");
// 打包实际组件和 React 为 CJS,CSS 置空;因此本测试不能证明最终样式或 Next 路由行为。
await build({
stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { AppFeedback } from "./src/components/ui/app-feedback"; export { IconButton } from "./src/components/ui/icon-button"; export { notify, dismissNotification } from "./src/lib/notifications"; export { BoardDirectory } from "./src/components/workspace/board-directory"; export { useCanvasTree } from "./src/components/canvas/use-canvas-tree"; export { useCanvasAI } from "./src/components/canvas/use-canvas-ai"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" },
outfile, bundle: true, platform: "node", format: "cjs", jsx: "automatic", logLevel: "silent", nodePaths: [resolve("node_modules")],
loader: { ".css": "empty" },
plugins: [{ name: "dynamic-component", setup(build) {
// Next Link 替换为 a,dynamic 替换为 lazy/Suspense;这些模块不经过实际 Next 运行时。
build.onResolve({ filter: /^next\/link$/ }, () => ({ path: "link", namespace: "fixture" }));
build.onResolve({ filter: /^next\/dynamic$/ }, () => ({ path: "dynamic", namespace: "fixture" }));
build.onLoad({ filter: /.*/, namespace: "fixture" }, ({ path }) => ({ contents: path === "link" ? 'import React from "react"; export default function Link(props) { return React.createElement("a",props); }' : 'import React from "react"; export default function dynamic(loader) { const Component = React.lazy(loader); return function Dynamic(props) { return React.createElement(React.Suspense, {fallback:null}, React.createElement(Component,props)); }; }', resolveDir: process.cwd() }));
// 只修改本次编译读入的 toolbar 文本来复现旧 portal 冒泡问题,不改磁盘源码。
if (process.env.ROTATION_BASELINE === "1") build.onLoad({ filter: /node-style-toolbar\.tsx$/ }, async ({ path }) => ({ contents: (await readFile(path, "utf8")).replace('onClick={(event) => event.stopPropagation()}', ''), loader: "tsx" }));
} }],
});
const { GraphCanvas, AppFeedback, IconButton, notify, dismissNotification, BoardDirectory, useCanvasAI, useCanvasTree, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile);
// 共享 React root 供各场景替换组件;tick 让 effect 推进,event 在 act 内选择事件类型并派发。
const root = createRoot(document.querySelector("#app"));
const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); };
// 非 feedback-only 才执行目录、轮询、旧画布工具和三个子场景;反馈场景总是执行。
if (process.env.CANVAS_UI_FEEDBACK_ONLY !== "1") {
{
// 目录 fixture 验证折叠、当前项祖先展开以及搜索保留完整父路径。
const boards = [{ id: "root", title: "主画布", workspace_id: "w", slug: "root-slug" }, { id: "child", title: "未命名分区", parent_board_id: "root", workspace_id: "w", slug: "child-slug" }, { id: "nested", title: "孙画布", parent_board_id: "child", workspace_id: "w" }];
const renderDirectory = (active, query = "") => h(BoardDirectory, { boards, workspace: "space", active, query, demo: false });
await act(async () => { root.render(renderDirectory("root-slug")); await tick(); });
@@ -81,12 +102,14 @@ try {
console.log("PASS directory: nested children, collapse, active ancestor expansion and search paths");
}
{
// 记录长定时任务并手动触发,检查空闲轮询退避和可见性暂停;finally 清理所有捕获计时器。
const originalSetTimeout = globalThis.setTimeout, originalClearTimeout = globalThis.clearTimeout;
const scheduled = [];
globalThis.setTimeout = (callback, delay, ...args) => { const handle = originalSetTimeout(callback, delay, ...args); if (delay >= 1000) scheduled.push({ callback, delay, handle }); return handle; };
let calls = 0, controls, renders = 0;
apiMock = async () => { calls++; return Response.json({ runs: [] }); };
const tree = { busy: false, writers: { current: new Map() } };
// 挂载 AI hook 并计数渲染,检查空结果轮询既不重复请求也不造成额外渲染。
function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; }
try {
await act(async () => { root.render(h(Probe)); await tick(); });
@@ -105,13 +128,16 @@ try {
console.log("PASS polling: single discovery, idle backoff, no empty rerenders, hidden pause and visible resume");
} finally { globalThis.setTimeout = originalSetTimeout; for (const item of scheduled) originalClearTimeout(item.handle); apiMock = null; }
}
// 播种 demo 旧快照、分区和边,随后以 DOM 事件检查工具栏和历史操作。
const node = makeCanvasNode("text", { x: 100, y: 100 }); node.id = "text-one";
const frameNode = makeCanvasNode("frame", { x: 500, y: 100 }); frameNode.id = "frame-one"; frameNode.data.title = "分区 A";
localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode, { ...makeCanvasNode("text", { x: 900, y: 100 }), id: "text-two" }], edges: [{ id: "edge-test", source: "text-one", target: "text-two", sourceHandle: "right", targetHandle: "left" }] }));
await act(async () => { root.render(h(GraphCanvas, { board: { id: "b", workspace_id: "w", kind: "board", title: "test", version: 1 } })); await tick(); });
await event(document.querySelector('[data-id="text-one"]'), "click");
// 每次重查旋转按钮,避免 portal 重渲染后持有旧引用。
const toggle = () => document.querySelector('[aria-label="旋转模式"]');
await event(toggle(), "click");
// baseline 预期复现即时取消;正常路径要求旋转保持并可 Escape、undo/redo。
if (process.env.ROTATION_BASELINE === "1") {
assert.equal(toggle().getAttribute("aria-pressed"), "false", "Baseline should reproduce immediate cancellation");
console.log("PASS baseline: portal click cancels rotation before the fix");
@@ -131,6 +157,7 @@ try {
assert.equal(requests.length, 0, "Closed picker and text-only board must not load icon shapes");
console.log("PASS rotation: portal click, pointer rotation, Escape, undo and redo");
// 分区进出通过 mouse 拖动触发,分别检查松开前预览、归属徽标和状态提示。
const textNode = document.querySelector('.react-flow__node[data-id="text-one"]');
await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 });
await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 });
@@ -149,6 +176,7 @@ try {
assert.ok(document.querySelector('[role="status"]').textContent.includes("已移出「分区 A」"));
console.log("PASS frames: actual drag previews, membership badge, count and join/leave announcements");
// 分区名称模式支持鼠标与键盘选择及历史,八向缩放控制可由 Escape 退出。
const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]');
await event(frameElement, "click");
const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]');
@@ -170,6 +198,7 @@ try {
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 0);
console.log("PASS frames: icon segments, no member count, keyboard choice, undo/redo and eight resize handles");
// 连线编辑覆盖曲线点增删、箭头 marker、常驻动画和无执行事件时停止动画。
await event(document.querySelector('.react-flow__edge[data-id="edge-test"]'), "click");
assert.ok(document.querySelector('[aria-label="连线编辑"]'));
await event(document.querySelector('[aria-label="添加曲线点"]'), "click");
@@ -186,6 +215,7 @@ try {
assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null);
console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation");
// IconPicker 以确定容器高度测试窗口化 DOM、按需分片、共享 Tabler 缓存和分类后滚动复位。
let choice;
await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); });
await act(async () => { await tick(); });
@@ -209,20 +239,25 @@ try {
assert.equal(grid.querySelector("button").dataset.index, "0", "Category change resets virtual window");
console.log("PASS picker: bounded DOM, on-demand data, shared Tabler load and selection");
}
// 正常模式追加 V2 linked-board 场景;baseline 跳过它,但成员和上传场景仍会执行。
if (process.env.ROTATION_BASELINE !== "1") {
let scenario;
await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); });
const { canvasV2Scenario } = scenario;
await canvasV2Scenario({ root, act, h, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
}
// 注入 hook 与 mock setter,成员场景可精确控制版本、能力和 ACK 竞态。
const { canvasMemberScenario } = await import("./canvas-member-scenario.mjs");
await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } });
// 上传场景复用 DOM/root,以文件、Image 和 API 替身检查预览、编辑与清理。
const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs");
await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
}
// 最后验证通用通知/tooltip 与 demo 画布提示,再卸载 root。
const { feedbackScenario } = await import("./feedback-scenario.mjs");
await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick });
await act(async () => { root.unmount(); await tick(); });
// 清理临时打包文件、JSDOM、esbuild 及 act 创建的 MessageChannel 端口,恢复原通道构造器。
} finally {
await rm(folder, { recursive: true, force: true }); window.close(); stop();
for (const channel of channels) { channel.port1.close(); channel.port2.close(); }
+23
View File
@@ -1,19 +1,31 @@
/**
* 旧快照画布的 DOM 上传回归场景,由 canvas-ui-check 注入 React root、事件和 API 替身。
* 不访问真实上传服务;文件内容是字符串 test,图片尺寸/加载事件由测试提供,不能证明真实图片解码或浏览器布局。
*/
import assert from 'node:assert/strict';
// 依次验证本地预览、上传期间编辑、失败重试、取消/删除、迟到响应、尺寸和卸载清理。
export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyCanvas,event,tick,mock}) {
// 固定画布与资源身份;asset 是模拟服务返回的受管原图地址和元数据。
const parent='cccccccc-cccc-4ccc-8ccc-cccccccccccc',assetID='dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const asset={id:assetID,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${assetID}`,width:640,height:1280,bytes:4,mime_type:'image/png'};
let snapshot,uploads,saves,release,mode;
// 把替身结果编码为 Response,走应用实际的响应处理路径。
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
// 包装 revokeObjectURL 记录释放行为,同时调用原实现;finally 恢复包装和 Image 构造器。
const revoked=[],originalRevoke=URL.revokeObjectURL;URL.revokeObjectURL=(url)=>{revoked.push(url);originalRevoke(url);};
const NativeImage=window.Image;
// 最多轮询 100 次,每轮等待 30ms 并通过 act 推进 effect;超时用 label 标记未完成阶段。
const wait=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);};
// 先卸载旧画布再清空草稿,为每种响应模式重建空快照、上传记录和 PATCH 记录。
async function reset(nextMode='delayed'){
await act(async()=>{root.render(null);await tick();});localStorage.clear();snapshot={version:1,document:{...emptyCanvas(),schema_version:2}};uploads=[];saves=[];release=null;mode=nextMode;
// 内存服务:上传可立即成功、403 失败或挂起;快照 PATCH 检查版本,并禁止持久化 blob URL。
mock(async(url,options={})=>{
const parts=new URL(url,'http://localhost').pathname.split('/'),suffix=parts.slice(8).join('/'),method=options.method||'GET';
if(parts.at(-1)==='assets'&&method==='POST'){
const file=options.body.get('image');uploads.push({key:options.headers['Idempotency-Key'],bytes:Buffer.from(await file.arrayBuffer()),signal:options.signal});
if(mode==='failed')return json({error:'forbidden'},403);
// 仅延迟模式保留 release,场景手动释放后才返回成功,制造本地编辑与 ACK 的竞态。
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
return json(asset,201);
}
@@ -28,6 +40,7 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC
});
await act(async()=>{root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Upload test',version:1},snapshot:structuredClone(snapshot)}));await tick();});
}
// 点击工具栏并注入 FileList 替身,再派发 change;只模拟选文件流程,不调用系统文件选择器。
async function choose(label='添加图片'){
await event(document.querySelector(`[aria-label="${label}"]`),'click');
const input=document.querySelector('input[type="file"][accept^="image/"]');
@@ -36,12 +49,15 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC
await wait(()=>uploads.length,'upload started');
}
try {
// 首次上传先检查 blob 预览已出现、字节到达替身,但服务端文档和保存记录仍为空。
await reset();await choose();
const local=document.querySelector('.react-flow__node img[src^="blob:"]');assert.ok(local,'Selected file immediately appears on the canvas before upload acknowledgement');const localURL=local.getAttribute('src');
assert.ok(document.querySelector('[aria-label="图片上传状态"]'));assert.equal(snapshot.document.nodes.length,0);assert.equal(saves.length,0);assert.deepEqual(uploads[0].bytes,Buffer.from('test'));
const imageNodeID=local.closest('.react-flow__node').dataset.id;
// 按最初节点 ID 每次重查 DOM,避免异步重渲染后继续持有已替换元素。
const editable=()=>document.querySelector(`.react-flow__node[data-id="${imageNodeID}"]`);
assert.ok(editable().classList.contains('draggable'),'Uploading images are real draggable nodes');
// 上传挂起时实际派发拖动、标题输入和缩放事件,记录结果用于 ACK 后比较。
await event(editable(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:280,clientY:250,buttons:1});await event(window,'mouseup',{clientX:280,clientY:250,button:0});
const editedPosition=editable().style.transform;
await event(editable().querySelector('[title="双击编辑名称"]'),'dblclick');
@@ -53,19 +69,23 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC
const resize=document.querySelector('[data-resize-handle="bottom-right"]'),surface=editable().querySelector('[style*="rotate("]');
await event(resize,'pointerdown',{clientX:400,clientY:400,button:0});await event(surface,'pointermove',{clientX:460,clientY:440,buttons:1});await event(surface,'pointerup',{clientX:460,clientY:440});await event(window,'keydown',{key:'Escape'});
const editedWidth=editable().style.width,editedHeight=editable().style.height;
// 上传不锁住其他内容创建;确认后先保留本地预览,等远端 load 事件才释放 blob URL。
await event(document.querySelector('[aria-label="添加文本卡片"]'),'click');assert.equal(document.querySelectorAll('.react-flow__node').length,2,'Other content can be added during upload');
await act(async()=>{release();await tick();});await wait(()=>document.querySelector('.react-flow__node img[src$="?variant=preview"]'),'managed image rendered');
assert.ok(document.querySelector(`img[src="${localURL}"]`),'Local image remains visible while remote preview loads');
const remote=document.querySelector('.react-flow__node img[src$="?variant=preview"]');await act(async()=>{remote.dispatchEvent(new window.Event('load'));await tick();});
assert.ok(!document.querySelector(`img[src="${localURL}"]`));assert.ok(revoked.includes(localURL));await wait(()=>snapshot.document.nodes.some(n=>n.data.src===asset.url),'original reference saved');
// 确认资源引用只保存一次,并保留上传期间的标题、位置和尺寸修改。
const persisted=snapshot.document.nodes.find(n=>n.id===imageNodeID);assert.equal(persisted.data.title,'上传前改名');assert.equal(editable().style.transform,editedPosition);assert.equal(editable().style.width,editedWidth);assert.equal(editable().style.height,editedHeight);
assert.equal(snapshot.document.nodes.filter(n=>n.data.src===asset.url).length,1);assert.equal(snapshot.document.nodes.find(n=>n.data.src===asset.url).data.assetId,assetID);
console.log('PASS uploads: immediate local preview, unrelated editing, original-byte attachment and no blob URL persistence');
// 403 后预览和文件仍保留;手动重试沿用第一次幂等键,最终只落一个节点。
await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'visible upload failure');
assert.ok(document.querySelector('img[src^="blob:"]'));assert.equal(saves.length,0);const key=uploads[0].key;mode='delayed';
await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='重试'),'click');assert.equal(uploads.length,2);assert.equal(uploads[1].key,key,'Manual retry retains the original idempotency key');
await act(async()=>{release();await tick();});await wait(()=>snapshot.document.nodes.length===1,'retry saves exactly one image');assert.equal(snapshot.document.nodes[0].data.src,asset.url);
console.log('PASS upload retry: retained file/preview, stable idempotency key and exactly one persisted node');
// 取消相当于可撤销的本地删除;随后单独检查上传中删除、ACK 迟到以及撤销后的受管资源恢复。
await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'failed cancellation');
const cancelledURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='取消'),'click');assert.equal(document.querySelector('img[src^="blob:"]'),null);assert.equal(saves.length,0);await event(document.querySelector('[aria-label="撤销"]'),'click');assert.ok(document.querySelector(`img[src="${cancelledURL}"]`),'Undo restores a locally deleted upload');
await reset();await choose();const deletedID=document.querySelector('.react-flow__node').dataset.id;
@@ -73,10 +93,13 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC
await act(async()=>{release();await tick();});await act(async()=>{await new Promise(r=>setTimeout(r,900));});assert.equal(snapshot.document.nodes.length,0,'Late upload completion must not resurrect a deleted node');
await event(document.querySelector('[aria-label="撤销"]'),'click');await wait(()=>snapshot.document.nodes.length===1,'undo after upload completed');assert.equal(snapshot.document.nodes[0].id,deletedID);assert.equal(snapshot.document.nodes[0].data.src,asset.url);
console.log('PASS editable uploads: move/resize/rename persist through acknowledgement, late completion respects deletion and undo');
// 尺寸替身异步回调 640×960;立即/延迟上传两条路径都应形成 320×480,元数据确认不额外占撤销步骤。
window.Image=class {naturalWidth=640;naturalHeight=960;set src(_){setTimeout(()=>this.onload?.(),0);}};
await reset('immediate');await choose('添加纯图片');await wait(()=>snapshot.document.nodes.length===1,'fast upload dimensions');assert.equal(snapshot.document.nodes[0].width,320);assert.equal(snapshot.document.nodes[0].height,480);await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(document.querySelector('.react-flow__node'),null,'Intrinsic size and attachment acknowledgement do not add undo steps');
await reset();await choose('添加纯图片');assert.equal(document.querySelector('.react-flow__node').style.width,'320px');assert.equal(document.querySelector('.react-flow__node').style.height,'480px');
// 卸载后检查 signal 已取消与 URL 已释放;再放行旧响应,不能触发文档保存。
const unmountedURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await act(async()=>{root.render(null);await tick();});assert.ok(uploads[0].signal.aborted);assert.ok(revoked.includes(unmountedURL));release();await tick();assert.equal(saves.length,0);
console.log('PASS upload cleanup: undoable deletion, unmount revocation/abort and plain-image aspect ratio');
// 无论断言是否失败,都恢复全局替身并卸载此场景的组件。
} finally {window.Image=NativeImage;URL.revokeObjectURL=originalRevoke;await act(async()=>{root.render(null);await tick();});}
}
+25
View File
@@ -1,18 +1,27 @@
/**
* 旧 schema_version=2 linked-board 画布的 JSDOM 集成场景,不是 headless 增量协议版本号。
* 真实组件消费内存 API 替身:快照、事务回执、AI 模型/事件和图片均由此文件生成;不调用模型供应商或实际 Foundry 服务。
*/
import assert from 'node:assert/strict';
// 依次检查只读加载、子图编辑/AI、折叠展开、分区 undo/redo、跨 owner 连线与图片处理。
export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvasNode,emptyCanvas,event,tick,mock}) {
// 构造父入口、子节点、外部 AI 文本与图片;父边引用入口 port,子内容只存在子快照中。
const parent='22222222-2222-4222-8222-222222222222', child='11111111-1111-4111-8111-111111111111';
const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='linked-frame';frame.data={...frame.data,title:'真实子图',subgraph:{boardId:child},ports:[{id:'out',nodeId:'child-text',handle:'right',direction:'output'}],presentation:{collapsed:false,contentScale:1,expandedSize:{width:640,height:400},collapsedSize:{width:240,height:140}}};
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='child-text';inside.data.title='子节点';inside.data.text='原始输入';
const target=makeCanvasNode('text',{x:950,y:100});target.id='outside';target.data.ai={enabled:true,modelId:'test-text',prompt:'继续整理',parameters:{}};
const image=makeCanvasNode('image',{x:1100,y:450},'https://example.com/reference.png');image.id='image-target';image.data.title='图片目标';
const legacy=makeCanvasNode('plain-image',{x:1600,y:100},'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB');legacy.id='legacy-image';
// Map 是模拟服务端快照事实;版本随每个写分支递增,不能与投影画面中的 :: ID 混用。
const records=new Map([[parent,{version:10,document:{...emptyCanvas(),schema_version:2,nodes:[frame,target,image,legacy],edges:[{id:'boundary',source:frame.id,sourceHandle:'port:out',target:target.id,targetHandle:'left',data:{animation:{mode:'executing',effect:'flow'}}}]}}],[child,{version:3,document:{...emptyCanvas(),schema_version:2,nodes:[inside]}}]]);
let connectionReceipt=null,connectionKey=null,connectionBody=null,connectionRequests=0;
const receipts=new Map();let createCount=0;
let assetUploads=0;
const saves=[];let job=null,poll=0,seq=0,imageRuns=0;const events=[];
// Response 包装走应用解码;emit 用递增 seq 支持 after_seq 事件过滤。
const json=(data,status=200)=>new Response(JSON.stringify(data),{status,headers:{'content-type':'application/json'}});
const emit=(type,data)=>events.push({seq:++seq,type,...data});
// 按 assets、快照、能力、模型、AI、连线和事务路径分流;未列出的请求立即失败。
mock(async(url,options={})=>{
if(new URL(url,'http://localhost').pathname.endsWith('/assets') && options.method==='POST') {
assetUploads++;assert.ok(options.headers['Idempotency-Key']);assert.ok(options.body.get('image').size>0);
@@ -22,6 +31,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
const parsed=new URL(url,'http://localhost'), parts=parsed.pathname.split('/');const board=parts[6], suffix=parts.slice(8).join('/'), method=options.method||'GET';
const snapshot=records.get(board);assert.ok(snapshot,`Unknown canvas ${board} ${url}`);
if(!suffix&&method==='GET')return json(structuredClone(snapshot));
// 快照保存检查 If-Match,禁止投影 ID 持久化和把子节点重复写入父画布。
if(!suffix&&method==='PATCH'){
if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412);
const payload=JSON.parse(options.body).document;
@@ -32,6 +42,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
if(suffix==='capabilities')return json({supported_schema_versions:[1,2],subgraphs:{connections:true}});
if(suffix==='ai/models')return json({models:[{id:'test-text',label:'测试文本模型',available:true,output_modalities:['text'],input_modalities:['text'],parameters_schema:{type:'object',properties:{},additionalProperties:false}},{id:'test-image',label:'测试图片模型',available:true,output_modalities:['image'],input_modalities:['text','image'],parameters_schema:{type:'object',properties:{},additionalProperties:false}}]});
if(suffix==='ai/runs'&&method==='GET')return json({runs:job&&['queued','running'].includes(job.status)?[{run_id:'test-run',board_id:child,status:job.status}]:[],next_cursor:null});
// 根据目标区分文本/图片模型及参与画布,校验幂等键与版本,再重置这次模拟 run 的事件序列。
if(suffix==='ai/runs'&&method==='POST'){
const input=JSON.parse(options.body);const isImage=input.node_id==='image-target';assert.equal(board,isImage?parent:child);assert.equal(input.node_id,isImage?'image-target':'child-text');assert.equal(input.model_id,isImage?'test-image':'test-text');assert.equal(input.propagate,!isImage);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);assert.ok(options.headers['Idempotency-Key']);
snapshot.document.nodes.find(node=>node.id===input.node_id).data.ai={enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:{},propagate:true};snapshot.version++;
@@ -40,6 +51,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
if(suffix.startsWith('ai/runs/image-run-')&&suffix.endsWith('/cancel')&&method==='POST'){
job.status='cancelled';emit('step.cancelled',{board_id:parent,node_id:'image-target'});return json({run_id:job.id,status:'cancelled'});
}
// 图片轮询第二次生成 src/assetId 并发出 output/applied;取消状态不走成功写回分支。
if(suffix.startsWith('ai/runs/image-run-')&&method==='GET'){
poll++;if(job.status!=='cancelled')job.status=poll<2?'running':'succeeded';
if(poll===2&&job.status==='succeeded'){
@@ -48,6 +60,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
}
const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:job.id,status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'image-step',board_id:parent,node_id:'image-target',status:job.status}],outputs:[],applied_versions:{}});
}
// 三轮推进子文本生成、跨边活跃及父文本传播;应用靠 seq 游标读取新增事件。
if(suffix==='ai/runs/test-run'&&method==='GET'){
poll++;
if(poll===1){job.status='running';emit('step.running',{board_id:child,node_id:'child-text'});}
@@ -55,6 +68,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
if(poll>=3&&job.status!=='succeeded'){job.status='succeeded';records.get(parent).document.nodes.find(node=>node.id==='outside').data.text='向下传播结果';records.get(parent).version++;emit('step.applied',{board_id:parent,node_id:'outside',canvas_version:records.get(parent).version,patch:{text:'向下传播结果'}});emit('edge.completed',{logical_edge_id:parent+'/boundary'});}
const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:'test-run',status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'one',board_id:child,node_id:'child-text',status:poll>=2?'succeeded':'running'},{step_id:'two',board_id:parent,node_id:'outside',status:poll>=3?'succeeded':poll>=2?'running':'queued'}],outputs:[],applied_versions:{}});
}
// 首次连线在父入口增 port 与父边并更新双方版本,故意回 502;重试核对原 body/key 后返回同一回执。
if(suffix==='connections'&&method==='POST'){
connectionRequests++;
if(connectionReceipt){assert.equal(options.headers['Idempotency-Key'],connectionKey);assert.equal(options.body,connectionBody);return json(connectionReceipt,201);}
@@ -67,11 +81,13 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
connectionReceipt={transaction_id:'tx-connect',parent:{board_id:parent,...structuredClone(records.get(parent))},snapshots:[{board_id:parent,...structuredClone(records.get(parent))},{board_id:child,...structuredClone(records.get(child))}],undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}};
return json({error:'upstream_unavailable'},502); // Simulate a lost response after the atomic commit.
}
// 创建的撤销检查当前父/子版本,以新父版本恢复原内容并返回归档子 ID;回执由内存生成。
if(suffix.startsWith('transactions/tx-create-')&&suffix.endsWith('/undo')&&method==='POST'){
const id=suffix.split('/')[1],receipt=receipts.get(id),input=JSON.parse(options.body);assert.ok(receipt);assert.deepEqual(Object.keys(input.expected_versions).sort(),[parent,receipt.child].sort());assert.equal(input.expected_versions[parent],records.get(parent).version);assert.equal(input.expected_versions[receipt.child],records.get(receipt.child).version);
const next={...receipt.before,version:records.get(parent).version+1};records.set(parent,next);records.get(receipt.child).version++;
return json({transaction_id:'undo-'+id,parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],archived_board_ids:[receipt.child],applied_versions:{[parent]:next.version,[receipt.child]:records.get(receipt.child).version}});
}
// 每次创建新子 ID 和事务回执,记录创建前父快照,供后续撤销与重做参与者检查。
if(suffix==='subgraphs'&&method==='POST'){
const input=JSON.parse(options.body).frame;const newChild=crypto.randomUUID();const tx='tx-create-'+(++createCount);const beforeCreate=structuredClone(snapshot);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);
const node=makeCanvasNode('frame',input.position);node.id=input.id;node.data.title=input.title;node.data.subgraph={boardId:newChild};snapshot.document.nodes.push(node);snapshot.version++;
@@ -81,6 +97,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
}
throw new Error(`Unhandled V2 API ${method} ${url}`);
});
// 用非整数 viewport 检查加载、测量与缩放都不写内容;视图状态仅记本地 viewport key。
const readOnly=structuredClone(records.get(parent));readOnly.document.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
await act(async()=>{root.render(h(GraphCanvas,{key:'read-only',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 read only',version:1},snapshot:readOnly}));await tick();});
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
@@ -94,10 +111,12 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
await act(async()=>{root.render(null);await tick();});
await act(async()=>{root.render(h(GraphCanvas,{key:'v2',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 test',version:1},snapshot:structuredClone(records.get(parent))}));await tick();});
await act(async()=>{await tick();});
// 通过 child::localId 查展开投影;只读挂载不得 PATCH,子节点必须只出现一次。
const node=()=>document.querySelector(`.react-flow__node[data-id="${child}::child-text"]`);
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
assert.equal(saves.length,0,'Opening a root and its child without edits must not PATCH');
assert.ok(node(),'Child is expanded from its own snapshot');assert.equal(document.querySelectorAll(`.react-flow__node[data-id="${child}::child-text"]`).length,1);
// 选择子节点进入 AI 模式,加载文本模型并开启下游传播;拖动与事件写回交错。
await event(node(),'click');await event(document.querySelector('[aria-label="AI 模式"]'),'click');await act(async()=>{await tick();});
assert.ok(document.querySelector('[aria-label="元素 AI 工具"]'));assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
await event(document.querySelector('[aria-label="继续向下传播"]'),'click');
@@ -105,6 +124,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
assert.ok(job);assert.equal(document.querySelector('[aria-label="AI 模式"]').getAttribute('aria-pressed'),'true');
// Move a child while the run executes. Its original owner receives the save.
await event(node(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:240,clientY:215,buttons:1});await event(window,'mouseup',{clientX:240,clientY:215,button:0});
// 有限轮询真实组件消费替身事件的结果;通过 act 推进 effect,而非直接替组件改 DOM。
const waitUntil=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,80));});}throw new Error('Timed out: '+label);};
await waitUntil(()=>node()?.querySelector('textarea')?.value==='生成后的子图内容','child AI result');
assert.ok(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),'Actual edge.active event starts execution-only animation');
@@ -112,6 +132,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="outside"] textarea')?.value==='向下传播结果','downstream AI result');
assert.equal(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),null,'Completed run stops execution-only animation');
assert.ok(saves.some(save=>save.board===child),'Child edits save to child canvas');
// 折叠隐藏投影、展开恢复,子局部位置应保持;随后新建分区并反复 undo/redo,检查新子参与者。
const childPosition=structuredClone(records.get(child).document.nodes[0].position);
await event(document.querySelector('.react-flow__node[data-id="linked-frame"]'),'click');
await event(document.querySelector('[aria-label="收起子画布"]'),'click');assert.equal(node(),null);assert.ok(document.querySelector('[aria-label="展开子画布"]'));
@@ -127,17 +148,20 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
await event(node(),'click');
const fromHandle=node().querySelector('[data-handleid="bottom"]');
const toHandle=document.querySelector('.react-flow__node[data-id="outside"] [data-handleid="left"]');
// JSDOM 没有真实命中布局,临时将 elementFromPoint 指向目标 handle,驱动连线事件后恢复。
const originalHitTest=document.elementFromPoint;document.elementFromPoint=()=>toHandle;
await event(fromHandle,'mousedown',{clientX:250,clientY:250,button:0,buttons:1});
await event(document,'mousemove',{clientX:500,clientY:250,buttons:1});
await event(document,'mouseup',{clientX:500,clientY:250,button:0});
document.elementFromPoint=originalHitTest;
await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='linked-frame').data.ports.some(port=>port.id==='generated-port'),'cross-canvas connection transaction');
// 等待不确定结果提示再点击重试;两次请求最终只产生一条额外边。
await waitUntil(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='重试同步'),'uncertain transaction recovery');
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='重试同步'),'click');
assert.equal(connectionRequests,2);assert.equal(document.querySelector('.react-flow__edge[data-id="'+connectionReceipt.parent.document.edges.at(-1).id+'"]')!==null,true);
assert.equal(records.get(parent).document.edges.length,2);
console.log('PASS connections: real handle gesture, capability gate, LCA transaction and exact participant versions');
// AI 模式保持开启但按新图片目标筛选模型;生成只更新资源,尺寸/标题保持,下一次 run 可取消。
await event(document.querySelector('.react-flow__node[data-id="image-target"]'),'click');
await act(async()=>{await tick();});
assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试图片模型'),'Sticky AI mode selects models for the newly selected image');
@@ -151,6 +175,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
await waitUntil(()=>document.querySelector('[aria-label="元素 AI 工具"]')?.textContent.includes('已取消生成'),'cancelled image run');
assert.equal(records.get(parent).document.nodes.find(node=>node.id==='image-target').data.src,generated.data.src);
console.log('PASS AI images: sticky mode, modality filtering, original-node asset output and cancellation');
// 现有 inline 图片不触发隐式后台上传或引用改写;受管图片继续使用 preview 渲染。
assert.equal([...document.querySelectorAll('button')].some(button=>button.textContent==='优化图片加载'),false,'Temporary migration controls are absent even with an inline image');
assert.equal(assetUploads,0,'Existing inline images do not trigger background uploads');
assert.ok(records.get(parent).document.nodes.find(node=>node.id==='legacy-image').data.src.startsWith('data:image/'),'Existing image references are not rewritten');
+17
View File
@@ -1,3 +1,7 @@
/**
* 旧 schema_version=2 linked-board 快照的兼容与投影测试。schema_version、headless 文档格式和增量协议版本不是同一编号。
* 检查展开分区、曲线/样式/执行配置、owner 投影与最低共同祖先;不把任意远程 AI 请求实际执行。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { decodeCanvas, emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts';
@@ -5,7 +9,9 @@ import { curveSegments, cubicPoint, insertCurveKnot } from '../src/lib/canvas-cu
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData } from '../src/lib/canvas-tree.ts';
import { resizeFromPointer } from '../src/lib/canvas-geometry.ts';
import { managedImageSource } from '../src/lib/canvas-v2.ts';
// 固定子 board 身份,投影时与符号节点 ID 组合,避免把 renderer ID 当作持久节点身份。
const childId = '11111111-1111-4111-8111-111111111111';
// 大尺寸 frame 引用子 board 和输出 port,父文字含 AI 配置,边包含完整曲线/动画/marker 数据。
function fixture() {
const frame = makeCanvasNode('frame', {x:100,y:100}); frame.id='frame'; frame.width=5000;
frame.data={...frame.data,subgraph:{boardId:childId},presentation:{collapsed:false,contentScale:.2,expandedSize:{width:5000,height:400},collapsedSize:{width:240,height:140}},ports:[{id:'output',direction:'output',nodeId:'inside',handle:'right'}]};
@@ -14,33 +20,40 @@ function fixture() {
const edge={id:'edge',source:'frame',target:'outside',sourceHandle:'port:output',targetHandle:'left',data:{appearance:{color:'#123456',width:2,line:'dashed',head:{type:'triangle',filled:false,size:12},tail:{type:'circle',size:8}},animation:{mode:'executing',effect:'flow',direction:'forward',durationMs:1200,color:'#ffffff'},path:{type:'bezier-chain',knots:[{id:'k',position:{x:700,y:150},in:{x:-30,y:0},out:{x:30,y:0},mode:'smooth'}]},execution:{enabled:true}}};
return {frame,text,inside,root:{...emptyCanvas(),schema_version:2,nodes:[frame,text],edges:[edge]},child:{...emptyCanvas(),schema_version:2,nodes:[inside]}};
}
// V2 JSON 往返保留 subgraph、AI 与全部边 data,且已解码对象再次解码结果稳定。
test('V2 roundtrip preserves linked frames, all curve/animation/marker data and AI config',()=>{
const {root}=fixture(); const decoded=decodeCanvas(JSON.stringify(root));
assert.deepEqual(decoded.edges[0].data,root.edges[0].data); assert.deepEqual(decoded.nodes[0].data.subgraph,root.nodes[0].data.subgraph); assert.deepEqual(decoded.nodes[1].data.ai,root.nodes[1].data.ai);assert.equal(decoded.nodes[0].width,5000);
assert.deepEqual(decodeCanvas(JSON.stringify(decoded)),decoded);
});
// 逐项破坏平滑控制向量、动画、线宽、端口方向、普通元素尺寸或 AI 参数容器,解码均失败。
test('V2 rejects bad control data, wrong port directions and oversized ordinary elements',()=>{
for(const mutate of [d=>d.edges[0].data.path.knots[0].out.x=-30,d=>d.edges[0].data.animation.mode='random',d=>d.edges[0].data.appearance.width=100,d=>d.nodes[0].data.ports[0].direction='input',d=>d.nodes[1].width=5000,d=>d.nodes[1].data.ai.parameters=[]]){
const {root}=fixture();mutate(root);assert.throws(()=>decodeCanvas(JSON.stringify(root)));
}
});
// 展开时子节点只投影一次且边解析到真实内部端点;折叠改画到 frame,不改父子原文档或子局部坐标。
test('expanded child nodes are projected once and boundary edges resolve to their actual nodes',()=>{
const {root,child}=fixture(), documents=new Map([['root',root],[childId,child]]);const view=projectCanvasTree('root',documents);
assert.equal(view.nodes.length,3);const projected=view.nodes.find(n=>n.id===childId+'::inside');assert.deepEqual(projected.position,{x:126,y:140});assert.equal(projected.width,52);assert.equal(view.nodeRefs.get(projected.id).boardId,childId);assert.equal(view.edges[0].source,projected.id);assert.equal(root.nodes.length,2);assert.equal(child.nodes.length,1);
root.nodes[0].data.presentation.collapsed=true;const collapsed=projectCanvasTree('root',documents);assert.equal(collapsed.nodes.length,2);assert.equal(collapsed.edges[0].source,'frame');assert.deepEqual(child.nodes[0].position,{x:10,y:20});
});
// 边的点与向量经 owner→projected→owner 转换应精确返回原 data。
test('curve coordinates and vectors invert exactly between owner and projected canvases',()=>{
const {root}=fixture();const ref={boardId:'b',id:'e',origin:{x:100,y:-40},scale:.2};assert.deepEqual(transformEdgeData(transformEdgeData(root.edges[0].data,ref),ref,true),root.edges[0].data);
});
// 插入中点把一段 cubic 拆为两段;101 个参数样本与原曲线距离小于 1e-8,验证形状而非仅端点。
test('adding a curve point preserves both halves of the original cubic',()=>{
const from={point:{x:0,y:0},normal:{x:1,y:0}},to={point:{x:500,y:200},normal:{x:-1,y:0}};
const old=curveSegments(from,to)[0], split=insertCurveKnot(from,to,undefined), next=curveSegments(from,to,split.path);
assert.equal(next.length,2);for(let i=0;i<=100;i++){const t=i/100,a=cubicPoint(old,t),b=cubicPoint(next[t<=.5?0:1],t<=.5?t*2:(t-.5)*2);assert.ok(Math.hypot(a.x-b.x,a.y-b.y)<1e-8);}
});
// 远程生成文字可与本地几何三方合并,但同字段本地文字冲突必须抛 version_conflict。
test('AI remote output merges with local geometry but conflicts with locally edited text',()=>{
const base=fixture().child,local=structuredClone(base),remote=structuredClone(base);local.nodes[0].position={x:200,y:300};remote.nodes[0].data.text='generated';
const merged=mergeCanvasDocuments(base,local,remote);assert.deepEqual(merged.nodes[0].position,{x:200,y:300});assert.equal(merged.nodes[0].data.text,'generated');local.nodes[0].data.text='user typing';assert.throws(()=>mergeCanvasDocuments(base,local,remote),/version_conflict/);
});
// 五类节点乘八方向在显式比例锁定时保持原比例;自由模式仅右侧拉伸不改图高,frame 可大于普通卡片上限。
test('eight-direction resize is free by default in resize mode and Shift locks the aspect',()=>{
for(const kind of ['plain-text','plain-image','text','image','frame']){
const node=makeCanvasNode(kind,{x:10,y:20});const oldRatio=node.width/node.height;
@@ -51,10 +64,12 @@ test('eight-direction resize is free by default in resize mode and Shift locks t
const image=makeCanvasNode('plain-image',{x:0,y:0});assert.equal(resizeFromPointer(image,'right',{x:100,y:0},false).height,image.height);
const frame=makeCanvasNode('frame',{x:0,y:0});assert.ok(resizeFromPointer(frame,'right',{x:10000,y:0},false).width>3000);
});
// 受管资产显示转认证同源代理,普通外部 URL 保留;这里只检查地址转换,不验证实际认证请求。
test('managed assets render through the authenticated same-origin proxy',()=>{
assert.equal(managedImageSource('https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/a'),'/api/foundry/workspaces/w/assets/a');assert.equal(managedImageSource('https://example.com/a.png'),'https://example.com/a.png');
});
// 连线持久 owner 是最低共同祖先,事务参与者恰为两条 owner 路径的并集;祖先本身不需要重复登记。
test('connection plan uses the lowest common ancestor and exactly both ownership paths',async()=>{
const {connectionTransactionPlan}=await import('../src/lib/canvas-subgraphs.ts');const {root,child}=fixture();
const nested='33333333-3333-4333-8333-333333333333';const frame=makeCanvasNode('frame',{x:0,y:0});frame.data.subgraph={boardId:nested};child.nodes.push(frame);
@@ -63,10 +78,12 @@ test('connection plan uses the lowest common ancestor and exactly both ownership
assert.equal(result.boardId,'root');assert.deepEqual(new Set(result.participants),new Set(['root',childId,nested]));assert.equal(result.source.node_id,'a');
const inner=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'bottom'},{boardId:childId,id:'b',handle:'top'});assert.equal(inner.boardId,childId);assert.deepEqual(new Set(inner.participants),new Set([childId,nested]));
});
// 旧 schema 或 subgraph owner 绑定不同的草稿不能恢复到已迁移 V2;等价深拷贝才可作为候选。
test('incompatible drafts cannot overwrite migrated V2 ownership',async()=>{
const {canRestoreCanvasDraft}=await import('../src/lib/canvas-sync.ts');const {root}=fixture();const old={...structuredClone(root),schema_version:1};assert.equal(canRestoreCanvasDraft(old,root),false);
const changed=structuredClone(root);changed.nodes[0].data.subgraph.boardId='55555555-5555-4555-8555-555555555555';assert.equal(canRestoreCanvasDraft(changed,root),false);assert.equal(canRestoreCanvasDraft(structuredClone(root),root),true);
});
// 同时拖选父 frame 与 child 的投影结果,只应用共同位移一次,反推 owner 局部坐标保持原值。
test('selecting both parent frame and child applies a shared drag once',async()=>{
const {referenceAfterMoves}=await import('../src/lib/canvas-tree.ts');const {root,child}=fixture();const docs=new Map([['root',root],[childId,child]]),view=projectCanvasTree('root',docs);
const projected=view.nodes.find(node=>node.id===childId+'::inside');const delta={x:50,y:80};const moved={x:projected.position.x+delta.x,y:projected.position.y+delta.y};
+14
View File
@@ -1,7 +1,12 @@
/**
* 旧画布快照 reducer 的基本历史、节点/边、序列化与分支规则测试。past/present/future 是旧版完整快照结构。
* 这里的 checkpoint/replace 不能当作 headless 操作账本、条件增量请求或项目恢复协议的验证。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
// 构造两张不同卡片及一条边,返回空 past/future 的独立历史,用于比较编辑前后内容和 viewport。
function seeded() {
const document = emptyCanvas();
document.nodes = [makeCanvasNode("text", { x: 20, y: 30 }), makeCanvasNode("script", { x: 400, y: 30 })];
@@ -9,6 +14,7 @@ function seeded() {
return { past: [], present: document, future: [] };
}
// checkpoint 后拖动形成一个历史起点;undo/redo 改内容位置但保留当前 viewport,视图导航不跟着旧快照回退。
test("node dragging updates its position; undo and redo retain the current viewport", () => {
let state = seeded();
const id = state.present.nodes[0].id;
@@ -23,6 +29,7 @@ test("node dragging updates its position; undo and redo retain the current viewp
assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 });
});
// 删节点连同附边一起删除,随后重复边删除通知不应再追加历史;一次 undo 恢复节点与边。
test("deleting a node removes attached edges and undo restores both", () => {
const initial = seeded();
const removed = canvasReducer(initial, { type: "nodes", changes: [{ id: initial.present.nodes[0].id, type: "remove" }] });
@@ -35,6 +42,7 @@ test("deleting a node removes attached edges and undo restores both", () => {
assert.equal(restored.present.edges.length, 1);
});
// 同一 checkpoint 内多次文字修改只留一个 past,undo 回到开始编辑前文字。
test("a text editing session is undoable as one operation", () => {
let state = seeded();
const id = state.present.nodes[0].id;
@@ -45,6 +53,7 @@ test("a text editing session is undoable as one operation", () => {
assert.equal(canvasReducer(state, { type: "undo" }).present.nodes[0].data.text, "");
});
// JSON 往返保留文本、图片、连线与 viewport,selected 作为临时 UI 字段不恢复。
test("export/import retains nodes, edges, text, images and the viewport", () => {
const document = seeded().present;
const image = makeCanvasNode("image", { x: 80, y: 90 }, "data:image/png;base64,YQ==");
@@ -60,6 +69,7 @@ test("export/import retains nodes, edges, text, images and the viewport", () =>
assert.deepEqual(restored.viewport, document.viewport);
});
// 悬空端点、重复 ID、不安全图片 URL 和非法 zoom 分别构造非法输入,任一项都使整个 decode 失败。
test("import rejects dangling connections, duplicate nodes, unsafe images and invalid zoom", () => {
for (const mutate of [
(doc) => { doc.edges[0].target = "missing"; },
@@ -73,11 +83,13 @@ test("import rejects dangling connections, duplicate nodes, unsafe images and in
}
});
// 旧草稿 key 包含 workspace 与 board,不能因同名图谱跨作用域覆盖。
test("draft storage is isolated by workspace and graph", () => {
assert.notEqual(canvasStorageKey("workspace-a", "board"), canvasStorageKey("workspace-b", "board"));
assert.notEqual(canvasStorageKey("workspace", "board-a"), canvasStorageKey("workspace", "board-b"));
});
// 50 次 replace 后 past 受限为 40;undo 产生 redo,新 add 必须清空 redo。
test("undo history is bounded and new edits clear the redo stack", () => {
let state = seeded();
for (let i = 0; i < 50; i++) state = canvasReducer(state, { type: "replace", document: { ...state.present, edges: [] } });
@@ -89,6 +101,7 @@ test("undo history is bounded and new edits clear the redo stack", () => {
});
// 四方向分支检查相对位置、选中切换、source/target ID 和相反 handle;快照可恢复,整组可撤销。
test("selected nodes can branch in all four directions with matching connection handles", () => {
const opposite = { top: "bottom", right: "left", bottom: "top", left: "right" };
for (const direction of Object.keys(opposite)) {
@@ -114,6 +127,7 @@ test("selected nodes can branch in all four directions with matching connection
}
});
// 同侧重复添加要避开已有节点,第二个新节点不能占据第一次相同位置。
test("repeated additions on the same side avoid existing nodes", () => {
const document = emptyCanvas();
const parent = makeCanvasNode("text", { x: 0, y: 0 });
+14
View File
@@ -1,16 +1,25 @@
/**
* 应用反馈组件的 JSDOM 场景:原生 title 回退、provider tooltip、通知更新及画布成功/错误提示。
* 只断言 DOM 属性、事件和状态,不证明真实屏幕阅读器播报或浏览器浮层排版。
*/
import assert from "node:assert/strict";
// 复用外层 React root,先验证独立控件,再验证带 AppFeedback 的画布交互。
export async function feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick }) {
await act(async () => { root.render(null); await tick(); });
let button, clicks = 0;
// 最小 SVG 图标替身只满足 IconButton 接口,不依赖真实图标资源。
const icon = () => h("svg", { "aria-hidden": true });
// ref 必须直接指向业务 button;点击计数用于检查 tooltip 不吞掉原操作。
const control = () => h(IconButton, { icon, label: "旋转工具", title: "旋转选中元素", ref: (value) => { button = value; }, onClick: () => { clicks++; } });
// 没有 provider 时保留原生 title;加入 provider 后不增加包裹层,改用关联 tooltip。
await act(async () => { root.render(control()); await tick(); });
assert.equal(button.title, "旋转选中元素", "Standalone controls retain native explanations");
await act(async () => { root.render(h(AppFeedback, null, h("div", { id: "controls" }, control()))); await tick(); });
assert.ok(document.querySelector("#controls").firstElementChild === button, "Tooltip does not wrap the button or alter toolbar geometry");
assert.equal(button.tagName, "BUTTON", "Forwarded ref still points at the actual button");
assert.equal(button.title, "", "A provider avoids duplicate native and Radix tooltips");
// 键盘聚焦应出现说明并建立 aria-describedby,Escape 隐藏说明后点击仍能触发业务回调。
await act(async () => { button.focus(); await tick(); });
const tooltip = document.querySelector('[role="tooltip"]');
assert.ok(tooltip?.textContent.includes("旋转选中元素"), "Keyboard focus exposes the explanation");
@@ -18,6 +27,7 @@ export async function feedbackScenario({ root, act, h, AppFeedback, IconButton,
await event(document, "keydown", { key: "Escape" });
assert.ok(!document.querySelector('[role="tooltip"]'), "Escape dismisses the tooltip");
await event(button, "click"); assert.equal(clicks, 1, "Tooltips preserve business clicks");
// 同一通知 ID 更新内容,只保留一个 toast;随后显式 dismiss 并等待退出清理。
await act(async () => {
notify({ id: "feedback-check", title: "正在处理" }); await tick();
notify({ id: "feedback-check", title: "已完成", level: "success" }); await tick();
@@ -30,12 +40,14 @@ export async function feedbackScenario({ root, act, h, AppFeedback, IconButton,
assert.ok(!document.querySelector('[data-sonner-toast]'), "Dismissed notifications are removed");
console.log("PASS feedback: native fallback, forwarded ref, keyboard tooltip/Escape, clicks and stable notification IDs");
// 播种旧版 demo 本地草稿,包含文本与分区;这不是服务端成员迁移测试。
const id = "feedback-board";
const node = { ...makeCanvasNode("text", { x: 100, y: 100 }), id: "feedback-text" };
const frame = { ...makeCanvasNode("frame", { x: 500, y: 100 }), id: "feedback-frame" };
frame.data.title = "提示分区";
localStorage.setItem(`endoleap_canvas_v1:w:${id}`, JSON.stringify({ ...emptyCanvas(), nodes: [node, frame] }));
await act(async () => { root.render(h(AppFeedback, null, h(GraphCanvas, { board: { id, workspace_id: "w", title: "反馈测试", kind: "board", version: 1 } }))); await tick(); });
// 拖入分区后同时核对成功提示和实际归属徽标,不能只看到 toast 就算操作生效。
const textNode = document.querySelector('.react-flow__node[data-id="feedback-text"]');
await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 });
await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 });
@@ -43,12 +55,14 @@ export async function feedbackScenario({ root, act, h, AppFeedback, IconButton,
await event(window, "mouseup", { clientX: 700, clientY: 200, button: 0 });
assert.ok(document.querySelector('[data-sonner-toast][data-type="success"]')?.textContent.includes("已加入「提示分区」"));
assert.ok(document.querySelector('[title="属于:提示分区"]'), "Notifications do not replace actual membership state");
// 用 PDF MIME 模拟不支持的图片选择,错误通知出现后继续创建文本,检查错误不阻塞编辑。
const fileInput = document.querySelector('input[type="file"][accept^="image/"]');
Object.defineProperty(fileInput, "files", { configurable: true, value: [new File(["test"], "test.pdf", { type: "application/pdf" })] });
await act(async () => { fileInput.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); });
assert.ok(document.querySelector('[data-sonner-toast][data-type="error"]')?.textContent.includes("演示模式请选择"));
await event(document.querySelector('[aria-label="添加文本卡片"]'), "click");
assert.equal(document.querySelectorAll('.react-flow__node').length, 3, "An error notification never blocks editing");
// 关闭此画布错误通知并卸载组件,避免影响后续共享 root 的场景。
await act(async () => { dismissNotification(`canvas:${id}:error`); root.render(null); await tick(); });
console.log("PASS canvas feedback: membership success, input errors and continued local editing through the application provider");
}
+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);
File diff suppressed because it is too large Load Diff
+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); } });

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