Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56a9806bc0 | |||
| dadfd0e41d | |||
| 5fd8312ef5 | |||
| f1c5853fea | |||
| 22119358f5 | |||
| aac3813ce4 | |||
| 8e3ad8c2a2 | |||
| db3eb8ec5d | |||
| cda4db5ec8 | |||
| fe80df375c | |||
| 4f75dcb9fd | |||
| 2a0b6bb9d9 | |||
| 412e31e898 | |||
| 6ad003eb37 | |||
| 50675ffed3 | |||
| bd728d2d94 | |||
| 6298202dd7 | |||
| f08310b83c | |||
| e0e4276e71 | |||
| 167e01af18 | |||
| 9d788e1712 | |||
| bd4f1a34a0 | |||
| dd36d4f795 | |||
| 9ae7d2d56a | |||
| bad68c6d59 | |||
| a1e3a32b61 | |||
| 7b0e734e16 | |||
| d5ab402592 | |||
| 3714af788f | |||
| 2f55f59bfc | |||
| dc0f25cad0 |
@@ -1,2 +1,4 @@
|
|||||||
|
# Next 服务端访问 Foundry 的源站;本地代理从这里发起后端 HTTP 请求,不是浏览器页面地址。
|
||||||
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
|
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
|
||||||
|
# 公开的项目路由标识,可进入浏览器构建;不是密码、会话令牌或授权凭证。
|
||||||
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
|
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
# 安装依赖不入库;依赖版本由包清单与 lock 文件记录。
|
||||||
node_modules/
|
node_modules/
|
||||||
|
# Next 开发/生产构建缓存与产物,不作为源代码提交。
|
||||||
.next/
|
.next/
|
||||||
|
# 独立 headless ESM 包由 scripts/build-headless.mjs 重建。
|
||||||
.headless-dist/
|
.headless-dist/
|
||||||
|
# 忽略本机环境变量文件;下一条只放行可公开的示例模板。
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
# TypeScript 增量检查缓存,由工具重新生成。
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
# macOS Finder 的目录元数据。
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
# 仓库根 docs 是本地研究/草案目录,当前约定不跟踪其中内容。
|
||||||
/docs/
|
/docs/
|
||||||
|
|||||||
@@ -142,3 +142,73 @@ Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局
|
|||||||
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
|
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
|
||||||
|
|
||||||
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。
|
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 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` 的公开项目标识与服务端源站说明写在模板内;实际凭据不应写进示例或公开构建变量。
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ for (const [key, nodes] of shards) {
|
|||||||
totalBytes += Buffer.byteLength(content);
|
totalBytes += Buffer.byteLength(content);
|
||||||
}
|
}
|
||||||
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
|
// 两份生成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/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n");
|
||||||
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
|
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
|
||||||
// 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。
|
// 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。
|
||||||
|
|||||||
@@ -1,91 +1,124 @@
|
|||||||
|
/* 画布与节点的局部样式:React Flow 全局类通过 :global 组合,状态类由组件/手势控制器切换;CSS 只控制呈现和命中,不负责数据归属、提交或锁。 */
|
||||||
|
/* 画布铺满定位父容器;随后覆盖 React Flow 背景、选中边框和 attribution 外观。 */
|
||||||
.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
|
.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) { --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; }
|
.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), .handMode :global(.react-flow__node) { cursor: grab; }
|
||||||
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
|
.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; }
|
.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; }
|
.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; }
|
.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, .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:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
|
||||||
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
|
.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; }
|
.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[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||||
.toolbar button:disabled { cursor: not-allowed; }
|
.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; }
|
.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 { 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; }
|
.toolbar .zoomValue:hover { background: #e9e7e3; }
|
||||||
|
/* 底部主工具栏相对画布居中;帮助、错误等浮层各自定位和设层级。 */
|
||||||
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
|
.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 { 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 strong { color: #49372e; font-size: 13px; }
|
||||||
.helpPopup p { margin-top: 9px; }
|
.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; }
|
.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; }
|
.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 { 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 { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
|
||||||
.migrationPanel button:disabled { cursor: default; opacity: .5; }
|
.migrationPanel button:disabled { cursor: default; opacity: .5; }
|
||||||
|
/* 旧/迁移画布表面铺满宿主,具体渲染内容由桥接组件决定。 */
|
||||||
.migrationSurface { position: absolute; inset: 0; }
|
.migrationSurface { position: absolute; inset: 0; }
|
||||||
|
/* 窄屏把右上工具条下移避让标题,底部工具条改左对齐并横向滚动,预留右侧帮助空间。 */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.topRight { top: 62px; right: 12px; }
|
.topRight { top: 62px; right: 12px; }
|
||||||
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
|
.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; }
|
.handMode .node textarea, .handMode .node img { pointer-events: none; }
|
||||||
|
|
||||||
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
|
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
|
||||||
|
|
||||||
|
/* 隐藏旧 handle 且禁用命中,新的中心点交互在后续规则控制。 */
|
||||||
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
|
.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; }
|
.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 { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
|
||||||
.plainFrame.selectedFrame { border-color: #a47764; }
|
.plainFrame.selectedFrame { border-color: #a47764; }
|
||||||
.plainText { padding: 4px; }
|
.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; }
|
.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; }
|
.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:global(.connectionindicator) { pointer-events: auto; }
|
||||||
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
|
.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; }
|
.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; }
|
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
|
||||||
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
|
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
|
||||||
|
/* 旋转时由 frame 接收手势,文本和中心连接点让出指针。 */
|
||||||
.rotatingFrame { cursor: grabbing; touch-action: none; }
|
.rotatingFrame { cursor: grabbing; touch-action: none; }
|
||||||
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: 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; }
|
.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 { 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 { 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 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 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; }
|
.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 { 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 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; }
|
.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 { 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 { 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 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 { 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; }
|
.branchMenu button:hover { background: #f1e6df; }
|
||||||
|
/* 节点图标填满内容区域,SVG 自身不截获指针,事件交给外层节点。 */
|
||||||
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
|
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
|
||||||
.iconContent svg { pointer-events: none; }
|
.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 { 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 { 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 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; }
|
.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 { 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 { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
|
||||||
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
|
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
|
||||||
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
|
.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 { 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; }
|
.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 { 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 { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
|
||||||
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
.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 { 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 { 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:hover { background-color: #f1e6df; }
|
||||||
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; }
|
.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; }
|
.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; }
|
.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; }
|
.rotatingFrame .membershipBadge { display: none; }
|
||||||
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
|
.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; }
|
.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 { 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; }
|
.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; }
|
.frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
|
||||||
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
|
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
|
||||||
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; }
|
.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.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
|
||||||
.frameNode.frameDropExit { border-style: dashed; }
|
.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; }
|
.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 { 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; }
|
.membershipNotice:empty { display: none; }
|
||||||
|
/* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */
|
||||||
.handMode .plainText { pointer-events: none; }
|
.handMode .plainText { pointer-events: none; }
|
||||||
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
|
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
|
||||||
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
|
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
|
||||||
.handMode .styleToolbar { display: none; }
|
.handMode .styleToolbar { display: none; }
|
||||||
|
/* 第一组减少动态效果规则关闭 frame、角缩放和中心点的过渡;后面还有分段滑块/连线动画的关闭规则。 */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
|
.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 { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
|
||||||
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
|
.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; }
|
.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; }
|
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
|
||||||
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
|
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
|
||||||
.frameBorder_bottom { bottom: -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_left { left: -5px; top: 0; bottom: 0; width: 10px; }
|
||||||
.frameBorder_right { right: -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; }
|
.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; }
|
.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 { 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%; }
|
.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 { 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::backdrop { background: #30251e33; }
|
||||||
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
|
.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 > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
|
||||||
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
|
.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 { 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; }
|
.textEditor textarea[readonly] { pointer-events: none; }
|
||||||
|
/* 透明选择层盖住内容区域,选中后给 grab 光标;手形模式取消该层命中。 */
|
||||||
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
|
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
|
||||||
.handMode .textSelectLayer { pointer-events: none; }
|
.handMode .textSelectLayer { pointer-events: none; }
|
||||||
|
|
||||||
.selectedFrame .textSelectLayer { cursor: grab; }
|
.selectedFrame .textSelectLayer { cursor: grab; }
|
||||||
.textSelectLayer:active { cursor: grabbing; }
|
.textSelectLayer:active { cursor: grabbing; }
|
||||||
|
/* 纯图片没有卡片 padding,图片仅显示不接受选择/拖动,外层控制变换。 */
|
||||||
.plainImageFrame { padding: 0; }
|
.plainImageFrame { padding: 0; }
|
||||||
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
|
.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; }
|
.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; }
|
.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 { 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 { 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; }
|
.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; }
|
.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; }
|
.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; }
|
.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 { 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 label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||||
.edgeToolbar input[type="number"] { width: 50px; }
|
.edgeToolbar input[type="number"] { width: 50px; }
|
||||||
@@ -156,17 +207,24 @@
|
|||||||
.edgeToolbar button:hover { background: #f1e6df; }
|
.edgeToolbar button:hover { background: #f1e6df; }
|
||||||
.edgeToolbar button:disabled { opacity: .4; }
|
.edgeToolbar button:disabled { opacity: .4; }
|
||||||
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
|
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
|
||||||
|
/* 曲线控制点用 pointer-events:all 开放 SVG 命中,触摸默认行为关闭,拖动逻辑由事件处理器负责。 */
|
||||||
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
|
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
|
||||||
|
/* 连线效果分别用 dashoffset 流动和 opacity 脉冲;是否附加动画类由执行状态/配置决定。 */
|
||||||
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
||||||
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
||||||
|
/* 虚线一个周期移动 40 单位;脉冲从低透明到不透明再返回。 */
|
||||||
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
||||||
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
|
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
|
||||||
|
/* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */
|
||||||
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
|
@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; }
|
.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 { display: flex; align-items: center; gap: 2px; font-size: 11px; }
|
||||||
.subgraphScale input { width: 45px; }
|
.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 { 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; }
|
.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 { 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 { 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; }
|
.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 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 { 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; }
|
.aiPanel footer button:disabled { opacity: .35; }
|
||||||
|
/* 普通提示与 role=alert 错误采用不同颜色;冲突候选区域独立滚动并保留预格式文本换行。 */
|
||||||
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
|
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
|
||||||
.aiPanel p[role="alert"] { color: #a25439; }
|
.aiPanel p[role="alert"] { color: #a25439; }
|
||||||
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
|
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
|
||||||
.aiConflict pre { white-space: pre-wrap; }
|
.aiConflict pre { white-space: pre-wrap; }
|
||||||
.aiConflict img { max-height: 100px; }
|
.aiConflict img { max-height: 100px; }
|
||||||
.aiConflict button { padding: 6px; color: #78523f; }
|
.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; }
|
.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; }
|
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
|
||||||
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
.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 { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
|
||||||
.framePort { opacity: 0; pointer-events: none; }
|
.framePort { opacity: 0; pointer-events: none; }
|
||||||
.selectedFrame .framePort { opacity: 1; }
|
.selectedFrame .framePort { opacity: 1; }
|
||||||
.framePort:global(.connectionindicator) { pointer-events: auto; }
|
.framePort:global(.connectionindicator) { pointer-events: auto; }
|
||||||
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.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 { 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 summary { cursor: pointer; padding: 5px 0; }
|
||||||
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
|
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
|
||||||
.importPanel p { margin: 5px 0; }
|
.importPanel p { margin: 5px 0; }
|
||||||
.importPanel small { color: #92877c; }
|
.importPanel small { color: #92877c; }
|
||||||
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
|
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
|
||||||
|
/* 导入动作横排,其余任务按钮使用文字色与局部留白,任务状态由 runtime/组件决定。 */
|
||||||
.importActions { display: flex; align-items: center; gap: 8px; }
|
.importActions { display: flex; align-items: center; gap: 8px; }
|
||||||
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
|
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
|
||||||
|
|||||||
@@ -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 { --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, .page button { -webkit-tap-highlight-color: transparent; }
|
||||||
.page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; }
|
.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; }
|
.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) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; }
|
||||||
.page :global(.brand img) { width: 30px; height: 30px; }
|
.page :global(.brand img) { width: 30px; height: 30px; }
|
||||||
.page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; }
|
.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 { display: flex; gap: 28px; font-size: 13px; color: #72766c; }
|
||||||
.nav nav a:hover { color: var(--ink); }
|
.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; }
|
.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 { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; }
|
||||||
.smallCta:hover { background: #f5f6f0; }
|
.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; }
|
.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; }
|
.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; }
|
.heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||||
.statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; }
|
.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 { 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 { 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; }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.primary:hover { background: #41453b; translate: 0 -1px; }
|
||||||
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
|
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
|
||||||
.textLink:hover { color: #21221f; }
|
.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; }
|
.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; }
|
.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; }
|
.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 { 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:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; }
|
||||||
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
|
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
|
||||||
.tinyLogo { font-size: 20px; color: #4c5841; }
|
.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; }
|
.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 { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; }
|
||||||
.wires circle { fill: #a3ad90; }
|
.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 { 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 > button:hover { translate: 0 -3px; }
|
||||||
.scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; }
|
.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; }
|
.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 { 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; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.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 { 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 > div { display: flex; gap: 5px; }
|
||||||
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
|
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
|
||||||
.previewFooter button[aria-pressed="true"] { background: #879567; }
|
.previewFooter button[aria-pressed="true"] { background: #879567; }
|
||||||
.previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; }
|
.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 { 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 > 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 { font-size: 13px; color: #646c58; letter-spacing: 0.3px; }
|
||||||
.manifestoStrip p span { color: #9ba28e; }
|
.manifestoStrip p span { color: #9ba28e; }
|
||||||
|
/* 功能区域限制最大宽度;标题与说明左右对齐,功能卡使用三列网格。 */
|
||||||
.space { max-width: 1320px; margin: auto; padding: 105px 48px; }
|
.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 { 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 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; }
|
.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 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
|
||||||
.featureGrid article { min-width: 0; }
|
.featureGrid article { min-width: 0; }
|
||||||
|
/* 每个功能插画使用固定高度裁剪容器,内部收集/连线/创作布局分别绘制。 */
|
||||||
.featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; }
|
.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 { 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 { 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(2) { rotate: 2deg; translate: 8px 0; }
|
||||||
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
|
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
|
||||||
.collectArt svg { width: 14px; }
|
.collectArt svg { width: 14px; }
|
||||||
|
/* 连线示例 SVG 铺满插画,两个端点卡片和装饰字形单独定位。 */
|
||||||
.connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; }
|
.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 { 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 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; }
|
.connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; }
|
||||||
|
/* 创作示例居中纸张和标题,编辑徽标独立固定在插画右下附近。 */
|
||||||
.createArt { display: grid; place-items: center; }
|
.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 { 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 > 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; }
|
.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; }
|
.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; }
|
.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 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; }
|
.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)); }
|
.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; }
|
.quote { position: relative; }
|
||||||
.quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; }
|
.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 h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; }
|
||||||
.quote em { display: block; font-style: normal; }
|
.quote em { display: block; font-style: normal; }
|
||||||
.quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; }
|
.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; }
|
.signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; }
|
||||||
|
/* 步骤纵向居中,每项含编号、文字和右侧不收缩的图标。 */
|
||||||
.workflowList { display: flex; flex-direction: column; justify-content: center; }
|
.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 { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; }
|
||||||
.workflowList > div:first-child { padding-top: 0; }
|
.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 h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
|
||||||
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
|
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
|
||||||
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; }
|
.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; }
|
.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 { 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 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 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; }
|
.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 { 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 h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; }
|
||||||
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
|
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
|
||||||
@@ -110,8 +138,11 @@
|
|||||||
.footer > p { font-size: 11px; color: #a1a992; }
|
.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 > 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; }
|
.footer > span { color: #abb39d; font: 8px ui-monospace, monospace; }
|
||||||
|
/* 断行标记默认隐藏,本文件没有另行显示它的媒体查询。 */
|
||||||
.mobileBreak { display: none; }
|
.mobileBreak { display: none; }
|
||||||
|
/* 大屏仅增大首屏上下留白。 */
|
||||||
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
|
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
|
||||||
|
/* 中屏压缩页面边距、标题、按钮及区块间距,仍保留主要双栏/三栏结构。 */
|
||||||
@media (max-width: 1050px) {
|
@media (max-width: 1050px) {
|
||||||
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
|
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
|
||||||
.hero { gap: 26px; }
|
.hero { gap: 26px; }
|
||||||
@@ -124,6 +155,7 @@
|
|||||||
.featureGrid { gap: 20px; }
|
.featureGrid { gap: 20px; }
|
||||||
.faq { gap: 45px; }
|
.faq { gap: 45px; }
|
||||||
}
|
}
|
||||||
|
/* 窄屏隐藏导航链接,将首屏、功能、工作流和 FAQ 转为单列,并缩小装饰及页脚内容。 */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
|
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
|
||||||
.nav nav { display: none; }
|
.nav nav { display: none; }
|
||||||
@@ -159,6 +191,7 @@
|
|||||||
.footer > p { display: none; }
|
.footer > p { display: none; }
|
||||||
.footer > span { flex-basis: 100%; }
|
.footer > span { flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
|
/* 极窄屏进一步压缩边距和预览卡片文字,隐藏品牌副标,避免最小视口拥挤。 */
|
||||||
@media (max-width: 380px) {
|
@media (max-width: 380px) {
|
||||||
.page :global(.brand small) { display: none; }
|
.page :global(.brand small) { display: none; }
|
||||||
.hero { padding-left: 20px; padding-right: 20px; }
|
.hero { padding-left: 20px; padding-right: 20px; }
|
||||||
@@ -170,4 +203,5 @@
|
|||||||
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
|
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
|
||||||
.noteTag { font-size: 7px; padding: 2px; }
|
.noteTag { font-size: 7px; padding: 2px; }
|
||||||
}
|
}
|
||||||
|
/* 按减少动态效果偏好关闭链接、按钮和 FAQ 图标的过渡。 */
|
||||||
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* IconButton 的共享尺寸、交互态和 tooltip 外观;尺寸从所在 Toolbar 的自定义属性继承,缺省为 32px 控件/20px 图标。 */
|
||||||
|
/* 固定 flex 尺寸避免拥挤时压缩;inline-flex 居中图标,透明背景显露宿主表面,文字颜色继承宿主。 */
|
||||||
.button {
|
.button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -13,10 +15,13 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
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 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:hover { background: #e9e7e3; }
|
||||||
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||||
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
.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; }
|
.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; }
|
.arrow { fill: #49372e; }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
|
||||||
|
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
|
||||||
.surface {
|
.surface {
|
||||||
--toolbar-icon-size: 20px;
|
--toolbar-icon-size: 20px;
|
||||||
--toolbar-control-width: 32px;
|
--toolbar-control-width: 32px;
|
||||||
@@ -18,5 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Text in the view controls already creates wider visual spacing. */
|
/* Text in the view controls already creates wider visual spacing. */
|
||||||
|
/* 含缩放文字的视图工具栏增加横向留白,不改变控件本身的尺寸。 */
|
||||||
.relaxed { column-gap: 6px; padding-inline: 6px; }
|
.relaxed { column-gap: 6px; padding-inline: 6px; }
|
||||||
|
/* 分隔线保持 1px flex 基准,避免被相邻控件挤宽或挤没。 */
|
||||||
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
|
.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; }
|
.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; }
|
.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 { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
|
||||||
.title:hover:not(:disabled) { background: #f0ece6; }
|
.title:hover:not(:disabled) { background: #f0ece6; }
|
||||||
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
|
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
|
||||||
.title:disabled { cursor: default; }
|
.title:disabled { cursor: default; }
|
||||||
|
/* 编辑态给定目标宽度但受上方 max-width 限制,readonly 时降低透明度。 */
|
||||||
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
|
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
|
||||||
.input[readonly] { opacity: 0.65; }
|
.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; }
|
.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 {
|
.shell {
|
||||||
--sidebar-width: 270px;
|
--sidebar-width: 270px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -5,6 +7,7 @@
|
|||||||
background: #fffefb;
|
background: #fffefb;
|
||||||
color: #37352f;
|
color: #37352f;
|
||||||
}
|
}
|
||||||
|
/* 侧栏插槽 sticky 固定在滚动视口顶部;裁剪展开过渡期间的内容,层级高于画布。 */
|
||||||
.sidebarSlot {
|
.sidebarSlot {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
/* 侧栏采用纵向 flex,导航区可滚动、账户区留在底部;最大宽度给主区域保留至少 40px。 */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -29,30 +33,38 @@
|
|||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
color: #37352f;
|
color: #37352f;
|
||||||
}
|
}
|
||||||
|
/* 顶行按钮分居两端;搜索框 focus-within 让输入聚焦时整个框有反馈。 */
|
||||||
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; }
|
.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 { 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: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 { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
|
||||||
.search input::placeholder { color: #9a9690; }
|
.search input::placeholder { color: #9a9690; }
|
||||||
|
/* flex:1 配合 min-height:0 才允许长目录在侧栏内部滚动。 */
|
||||||
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
|
.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, .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; }
|
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
|
||||||
.pageLink { margin: 1px 0; }
|
.pageLink { margin: 1px 0; }
|
||||||
.pageLink svg { flex-shrink: 0; color: #87837c; }
|
.pageLink svg { flex-shrink: 0; color: #87837c; }
|
||||||
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
|
.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; }
|
.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; }
|
.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; }
|
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
|
||||||
|
/* 账户区域向侧栏底部靠齐,按钮占满宽度以便整行点击。 */
|
||||||
.accountArea { margin-top: auto; padding-top: 6px; }
|
.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 { 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; }
|
.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 { 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; }
|
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
|
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
|
||||||
|
/* 账户文字允许收缩和省略,副文字在此布局隐藏。 */
|
||||||
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
|
.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 strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.accountText small { display: none; }
|
.accountText small { display: none; }
|
||||||
|
/* 原生 popover 的面板位置/宽度约束;自身可滚动且从左下角进入,不用 CSS 切换 open 状态。 */
|
||||||
.accountPopup {
|
.accountPopup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: auto auto 50px 8px;
|
inset: auto auto 50px 8px;
|
||||||
@@ -73,31 +85,40 @@
|
|||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
transform-origin: bottom left;
|
transform-origin: bottom left;
|
||||||
}
|
}
|
||||||
|
/* 仅 popover 打开时播放进入动画;backdrop 保持透明,但是否可交互仍由 popover 行为决定。 */
|
||||||
.accountPopup:popover-open { animation: account-enter 130ms ease-out; }
|
.accountPopup:popover-open { animation: account-enter 130ms ease-out; }
|
||||||
.accountPopup::backdrop { background: transparent; }
|
.accountPopup::backdrop { background: transparent; }
|
||||||
|
/* 菜单头、工作区字母头像及身份行共用纵向文字布局,长名称省略。 */
|
||||||
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; }
|
.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; }
|
.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 > 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 strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.accountHeading small { color: #8b8780; font-size: 12px; }
|
.accountHeading small { color: #8b8780; font-size: 12px; }
|
||||||
|
/* 菜单分组和身份区域提供内间距,邮箱允许断行;工作区列表限制高度后独立滚动。 */
|
||||||
.menuGroup { padding: 1px 0; }
|
.menuGroup { padding: 1px 0; }
|
||||||
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
|
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
|
||||||
.popupIdentity strong { font-size: 12px; font-weight: 500; }
|
.popupIdentity strong { font-size: 12px; font-weight: 500; }
|
||||||
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
|
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
|
||||||
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; }
|
.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 { 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 > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.menuItem > svg { flex-shrink: 0; }
|
.menuItem > svg { flex-shrink: 0; }
|
||||||
.menuItem:hover { background: #f1f0ed; }
|
.menuItem:hover { background: #f1f0ed; }
|
||||||
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; }
|
.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; }
|
.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; }
|
.newWorkspace { color: #2383e2; }
|
||||||
.logout { color: #78746d; font-size: 12px; }
|
.logout { color: #78746d; font-size: 12px; }
|
||||||
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
|
.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); }
|
.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); }
|
.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; }
|
.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; }
|
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
|
||||||
|
/* 设置表单纵向排布身份、字段及操作;提交按钮区分主动作,禁用时保留位置。 */
|
||||||
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
|
.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; }
|
.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; }
|
.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 { 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[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
|
||||||
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
.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; }
|
.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; }
|
.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; }
|
.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); } }
|
@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 (max-width: 600px) { .shell { --sidebar-width: 240px; } }
|
||||||
|
/* 减少动态效果偏好下关闭菜单进入动画;浮动条过渡在文件后部单独关闭。 */
|
||||||
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
|
@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 { 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 img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); }
|
||||||
.logoLink:hover { background: #e9e7e3; }
|
.logoLink:hover { background: #e9e7e3; }
|
||||||
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
.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 { 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; }
|
.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; }
|
.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; }
|
.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; }
|
.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; } }
|
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
|
||||||
|
/* 极窄且侧栏仍展开时隐藏 logo/标题/分隔,保留浮动条其余控件。 */
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
.floating[data-sidebar-collapsed="false"] .logoLink,
|
.floating[data-sidebar-collapsed="false"] .logoLink,
|
||||||
.floating[data-sidebar-collapsed="false"] .floatingTitle,
|
.floating[data-sidebar-collapsed="false"] .floatingTitle,
|
||||||
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
|
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
|
||||||
}
|
}
|
||||||
|
/* 按用户偏好关闭浮动条位移/宽度过渡。 */
|
||||||
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
|
||||||
|
|
||||||
|
/* 递归目录清除列表默认缩进,层级缩进由组件布局提供;行本身允许标题收缩。 */
|
||||||
.directoryList { list-style: none; padding: 0; margin: 0; }
|
.directoryList { list-style: none; padding: 0; margin: 0; }
|
||||||
.directoryRow { display: flex; align-items: center; min-width: 0; }
|
.directoryRow { display: flex; align-items: center; min-width: 0; }
|
||||||
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
|
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
|
||||||
|
/* 可折叠项按钮与叶项占位共享 20px 列宽,保持各行标题对齐;焦点内描边不扩尺寸。 */
|
||||||
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
|
.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 { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
|
||||||
.directoryToggle:hover { background: #eae7e2; }
|
.directoryToggle:hover { background: #eae7e2; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。
|
* 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。
|
||||||
* 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转,
|
* 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转,
|
||||||
* 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。
|
* 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。
|
||||||
|
* 数学辅助函数依赖已校验的有限几何/正比例;它们不逐项代替 schema、registry 或可见性校验。
|
||||||
*/
|
*/
|
||||||
import type { Document, Geometry, Id, Json, Point } from './types.ts';
|
import type { Document, Geometry, Id, Json, Point } from './types.ts';
|
||||||
import { ancestors } from './document.ts';
|
import { ancestors } from './document.ts';
|
||||||
@@ -22,9 +23,10 @@ export function toWorld(transform: Transform, point: Point): Point { const value
|
|||||||
export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); }
|
export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); }
|
||||||
/** 将可识别的 JSON 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */
|
/** 将可识别的 JSON 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */
|
||||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||||
/** 对已识别的 bezier-chain 换空间;未知路径原样返回,严格写入边界由 convertPath 把关。
|
/** type/knots 可识别时逐点换空间;其它路径原样返回,不在本函数完整验证曲线形状。
|
||||||
* knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */
|
* knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */
|
||||||
export function transformPath(path: Json, transform: Transform, inverse = false): Json {
|
export function transformPath(path: Json, transform: Transform, inverse = false): Json {
|
||||||
|
// 直接调用可对部分字段做转换;实际需要换空间时,convertPath 会先检查支持的路径格式。
|
||||||
const raw = object(path);
|
const raw = object(path);
|
||||||
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
|
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
|
||||||
/** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */
|
/** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */
|
||||||
@@ -42,13 +44,15 @@ export function transformPath(path: Json, transform: Transform, inverse = false)
|
|||||||
/** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。
|
/** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。
|
||||||
* 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */
|
* 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */
|
||||||
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
|
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
|
||||||
|
// unitScale 由专用读取器检查;contentScale 在这里仅按 number 且 >0 选择,有限性等约束依赖上游 schema。
|
||||||
const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units;
|
const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units;
|
||||||
// 入口绕中心旋转,因此先把内容内边距写成相对中心偏移,再旋转并加回中心。
|
// 入口绕中心旋转,因此先把内容内边距写成相对中心偏移,再旋转并加回中心。
|
||||||
const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation);
|
const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation);
|
||||||
const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y});
|
const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y});
|
||||||
return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360};
|
return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360};
|
||||||
}
|
}
|
||||||
/** 从根向目标逐级合成入口变换;ancestors 同时检查缺失父项与循环。 */
|
/** 从根向目标逐级合成入口变换;ancestors 检查缺失父项与循环。
|
||||||
|
* 这里只要求每层入口存在且为 node,不检查组件是否已知/展开;可编辑性与可见性由上层决定。 */
|
||||||
export function canvasTransform(document:Document,canvasId:Id):Transform {
|
export function canvasTransform(document:Document,canvasId:Id):Transform {
|
||||||
let result:Transform={...origin};
|
let result:Transform={...origin};
|
||||||
for(const id of ancestors(document,canvasId).reverse()){
|
for(const id of ancestors(document,canvasId).reverse()){
|
||||||
@@ -65,7 +69,8 @@ export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):G
|
|||||||
const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio;
|
const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio;
|
||||||
return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360};
|
return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360};
|
||||||
}
|
}
|
||||||
/** 严格曲线换空间:null/同变换无需改;不支持的路径拒绝,不擅自解释未知 payload。 */
|
/** null/同变换先原样返回;只有实际需要换空间时才检查支持的曲线格式,不支持就拒绝。
|
||||||
|
* 两次转换分别完成旧局部→世界、世界→新局部;无须改变 knot ID 或曲线段拓扑。 */
|
||||||
export function convertPath(path:Json,from:Transform,to:Transform):Json {
|
export function convertPath(path:Json,from:Transform,to:Transform):Json {
|
||||||
if(path===null||sameTransform(from,to))return path;
|
if(path===null||sameTransform(from,to))return path;
|
||||||
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
|
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。
|
* 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。
|
||||||
* 只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。
|
* 普通路径段只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。
|
||||||
|
* path 应先经组件定义校验:本遍历器不重新禁止空路径或原型敏感键,数组 * 按下标位置枚举。
|
||||||
*/
|
*/
|
||||||
import type { Json } from './types.ts';
|
import type { Json } from './types.ts';
|
||||||
import { fault } from './values.ts';
|
import { fault } from './values.ts';
|
||||||
@@ -14,7 +15,7 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
|
|||||||
const {value, depth} = work.pop()!, key = path[depth];
|
const {value, depth} = work.pop()!, key = path[depth];
|
||||||
if (key === '*') {
|
if (key === '*') {
|
||||||
if (!Array.isArray(value)) throw fault('invalid_copy_reference');
|
if (!Array.isArray(value)) throw fault('invalid_copy_reference');
|
||||||
// 终端 * 直接枚举数组项位置;非终端 * 继续匹配下一路径段。
|
// 终端 * 直接枚举 0..length-1,不另用 hasOwn 检查空洞;非终端 * 继续匹配下一路径段。
|
||||||
if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; }
|
if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; }
|
||||||
else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1});
|
else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1});
|
||||||
} else if (value && typeof value === 'object' && Object.hasOwn(value, key)) {
|
} else if (value && typeof value === 'object' && Object.hasOwn(value, key)) {
|
||||||
@@ -23,7 +24,8 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** 在 slot 上读取引用 ID;null 代表无引用,many 要求数组,每个 ID 必须非空字符串。 */
|
/** 在 slot 上读取引用 ID;整个 slot 为 null 表示无引用,many 要求数组,每个 ID 必须非空字符串。
|
||||||
|
* many 数组里的 null 不等于无引用,会拒绝;many 与路径中的 * 是独立的两层展开规则。 */
|
||||||
export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
|
export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
|
||||||
for (const {parent, key} of copySlots(data, ref.path)) {
|
for (const {parent, key} of copySlots(data, ref.path)) {
|
||||||
const value = parent[key]; if (value === null) continue;
|
const value = parent[key]; if (value === null) continue;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表,
|
* 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表,
|
||||||
* 累计 64 项后折叠为新 base。只用于尚未发布的 draft,提交前统一物化为标准文档表。
|
* 已有追加层达到 64 项时,下一次追加先折叠为新 base。只用于未发布 draft,提交前统一物化文档表。
|
||||||
|
* 输入表须已具固定成员;视图借用 base 和未变记录,不是任意可变 Map 的隔离器。
|
||||||
*/
|
*/
|
||||||
import {detachElementData} from './element-data.ts';
|
import {detachElementData} from './element-data.ts';
|
||||||
import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts';
|
import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts';
|
||||||
@@ -14,12 +15,14 @@ const overlays=new WeakMap<object,{base:ReadonlyMap<Id,unknown>;added:ReadonlyMa
|
|||||||
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
|
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
|
||||||
if(input.has(id))throw fault('duplicate_identity',[id]);
|
if(input.has(id))throw fault('duplicate_identity',[id]);
|
||||||
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined;
|
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined;
|
||||||
// 控制覆盖层大小;到阈值后重建 base,而不是让链条随着事务长度无限加深。
|
// 当前 input 若含 64 个 added,本次先通过 readMap(input) 折叠,再以新 added 放入第 65 个;不是整个事务最多 64 项。
|
||||||
if(previous&&previous.added.size>=64)previous=undefined;
|
if(previous&&previous.added.size>=64)previous=undefined;
|
||||||
|
// overlays 记录的是框架创建的视图身份;普通输入直接借用,只有已知旧 overlay 才需要折叠。
|
||||||
const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added);
|
const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added);
|
||||||
added.set(id,value);
|
added.set(id,value);
|
||||||
/** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */
|
/** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */
|
||||||
const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;};
|
const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;};
|
||||||
|
// get 用 has 区分“added 中显式存 undefined”与缺失;迭代顺序始终 base 在前、追加成员在后。
|
||||||
// keys/values/iterator 复用 entries;forEach 第三个参数仍是安全视图,不泄漏 added Map。
|
// keys/values/iterator 复用 entries;forEach 第三个参数仍是安全视图,不泄漏 added Map。
|
||||||
const view:ReadonlyMap<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries,
|
const view:ReadonlyMap<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries,
|
||||||
*keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
|
*keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
|
||||||
@@ -29,14 +32,17 @@ function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
|
|||||||
}
|
}
|
||||||
/** Internal editor path: input tables already have fixed membership. */
|
/** Internal editor path: input tables already have fixed membership. */
|
||||||
/** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。
|
/** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。
|
||||||
* 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。 */
|
* 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。
|
||||||
|
* 本层只检查创建形状、JSON、ID 一致及活表重复;registry 支持与墓碑禁复用由 editor/最终校验负责。 */
|
||||||
export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{
|
export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{
|
||||||
if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
|
if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
|
||||||
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
|
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
|
||||||
if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]);
|
if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]);
|
||||||
const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after));
|
const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after));
|
||||||
|
// 只替换被追加的表,另一表/墓碑沿用;新 Document 标记为 draft,旧视图不随本次 added 改变。
|
||||||
const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next;
|
const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next;
|
||||||
}
|
}
|
||||||
/** Only published documents use the core's owned table envelopes. */
|
/** Materialize transaction-local append tables before publication. */
|
||||||
/** 只有已标记的 draft 需要通过空 changes 重新捕获表;普通文档原样复用。 */
|
/** 只有已标记 draft 通过空 changes 物化成工厂 envelope/readMap;其它文档原样返回。
|
||||||
|
* 不在这里校验 schema/树,也不把任何外来 Document 自动视为已校验或深度不可变。 */
|
||||||
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document;
|
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document;
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ export function validateCreationSizeEffect(value: unknown): asserts value is Cre
|
|||||||
* Already-issued operation bodies are deliberately outside this function.
|
* Already-issued operation bodies are deliberately outside this function.
|
||||||
*/
|
*/
|
||||||
/** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。
|
/** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。
|
||||||
* 返回候选 document/history/changes;原输入始终不变,失败不留下半份历史。 */
|
* 返回候选 document/history/changes,不发布状态;输入须来自已捕获的不可变文档与历史。
|
||||||
|
* 这里按 geometry.resize 能力判定,不限定 componentId 必须是 image;图片授权条件由外层 grant 决定。 */
|
||||||
export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
|
export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
|
||||||
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
|
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
|
||||||
/** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */
|
/** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */
|
||||||
const fail = (code: string): never => { throw fault(code, [target.id]); };
|
const fail = (code: string): never => { throw fault(code, [target.id]); };
|
||||||
if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size');
|
if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size');
|
||||||
|
// 创建项必须仍在已应用 undo 时间线上;已撤销到 redo、被淘汰或目标换代的尺寸结果不能采用。
|
||||||
const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId);
|
const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId);
|
||||||
const current = document.elements.get(target.id);
|
const current = document.elements.get(target.id);
|
||||||
if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed');
|
if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed');
|
||||||
@@ -40,7 +42,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
|||||||
const units = unitScale(created.data.presentation);
|
const units = unitScale(created.data.presentation);
|
||||||
const baseline = { width: created.geometry.width / units, height: created.geometry.height / units };
|
const baseline = { width: created.geometry.width / units, height: created.geometry.height / units };
|
||||||
const ratio = { width: size.width / baseline.width, height: size.height / baseline.height };
|
const ratio = { width: size.width / baseline.width, height: size.height / baseline.height };
|
||||||
/** 同一生命周期的节点才属于本次自动尺寸修正对象。 */
|
/** 调用处已按 target.id 取记录,这里再限制 incarnation 与 node 结构;不单独比较元素 ID。 */
|
||||||
const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
|
const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
|
||||||
/** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */
|
/** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */
|
||||||
const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node'
|
const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node'
|
||||||
@@ -52,9 +54,11 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
|||||||
// Rewind on private roots. applyChanges intentionally preserves historical
|
// Rewind on private roots. applyChanges intentionally preserves historical
|
||||||
// incarnations here; this is inspection, not a user-visible delete/restore.
|
// incarnations here; this is inspection, not a user-visible delete/restore.
|
||||||
let original = document;
|
let original = document;
|
||||||
// 从创建项起回退已应用历史,得到创建前私有基线;不产生用户可见的删除/恢复代次。
|
// 从创建项起回退全部已应用项,得到创建前私有基线;此处用 applyChanges 而非 planHistoryChanges,
|
||||||
|
// 因而允许检查途中存在历史代次/墓碑组合,不把它们作为最终文档发布。
|
||||||
const applied = history.undo.slice(index);
|
const applied = history.undo.slice(index);
|
||||||
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
|
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
|
||||||
|
// original 追踪旧时间线,amended 追踪修正后的时间线;inheriting 只在匹配创建之后开启。
|
||||||
let amended = original, inheriting = false;
|
let amended = original, inheriting = false;
|
||||||
const entries = new Map(history.entries);
|
const entries = new Map(history.entries);
|
||||||
// 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。
|
// 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。
|
||||||
@@ -63,11 +67,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
|||||||
const id = timeline[cursor], source = entries.get(id)!;
|
const id = timeline[cursor], source = entries.get(id)!;
|
||||||
const end = applyChanges(original, source.changes), isApplied = cursor < applied.length;
|
const end = applyChanges(original, source.changes), isApplied = cursor < applied.length;
|
||||||
if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized');
|
if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized');
|
||||||
// A future explicit resize remains a resize to the user's chosen dimensions.
|
// A future resize preserves each dimension explicitly changed by the user.
|
||||||
// Only its before value inherits the asynchronous default correction.
|
// Its before value and unchanged dimensions may inherit the default correction.
|
||||||
const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
|
const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
|
||||||
let resizeConsumed = false;
|
let resizeConsumed = false;
|
||||||
/** redo 中用户显式改过的维度停止继承默认比例;仅修正其 before,不改用户指定的 after。 */
|
/** redo 中按宽/高分别判断:被用户改过的维度将 ratio 置 1,保留其 after;
|
||||||
|
* before 重取修正时间线,未被用户改变的另一维仍可继承默认比例。每项最多消费一次。 */
|
||||||
const preserveFutureResize = () => {
|
const preserveFutureResize = () => {
|
||||||
if (!futureResize || resizeConsumed) return;
|
if (!futureResize || resizeConsumed) return;
|
||||||
const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' };
|
const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' };
|
||||||
@@ -76,9 +81,10 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
|||||||
resizeConsumed = true;
|
resizeConsumed = true;
|
||||||
};
|
};
|
||||||
const changes: Change[] = [];
|
const changes: Change[] = [];
|
||||||
// 每条 next 都基于 amended 的实际 before 重建,保证修正后的连续历史仍条件匹配。
|
// 目标整记录/geometry 变化从 amended 重取 before;其它变化保留原条件,继续用 applyChanges 校验。
|
||||||
for (const change of source.changes) {
|
for (const change of source.changes) {
|
||||||
let next = change;
|
let next = change;
|
||||||
|
// 整记录变化也要重取真实 before;其它对象/字段沿用原变化,保证整条时间线仍按原次序检查。
|
||||||
if (change.kind === 'element' && change.id === target.id) {
|
if (change.kind === 'element' && change.id === target.id) {
|
||||||
const before = amended.elements.get(target.id) ?? null;
|
const before = amended.elements.get(target.id) ?? null;
|
||||||
const birth = id === entryId && change === creations[0];
|
const birth = id === entryId && change === creations[0];
|
||||||
@@ -98,6 +104,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
|||||||
amended = applyChanges(amended, [next]);
|
amended = applyChanges(amended, [next]);
|
||||||
changes.push(next);
|
changes.push(next);
|
||||||
}
|
}
|
||||||
|
// 整项完成后再推进旧时间线;只有变化内容确实不同才替换 entries 中这一项,operationIds/标签保持。
|
||||||
original = end;
|
original = end;
|
||||||
if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes }));
|
if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes }));
|
||||||
}
|
}
|
||||||
@@ -112,10 +119,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi
|
|||||||
]);
|
]);
|
||||||
const nextDocument = applyChanges(document, changes);
|
const nextDocument = applyChanges(document, changes);
|
||||||
validateDocument(nextDocument, registry);
|
validateDocument(nextDocument, registry);
|
||||||
// 最后用组件自己的 resize handler 对照约束,自动尺寸不能绕过组件 min/max/aspect 政策。
|
// 调用已安装 resize handler,把其生成变化应用后的目标记录与直接候选比较;不一致就拒绝。
|
||||||
|
// 这能执行组件实际实现的限制,不凭 capability 名称宣称所有组件都有 min/max/aspect 政策。
|
||||||
const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize');
|
const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize');
|
||||||
const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) });
|
const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) });
|
||||||
for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change');
|
for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change');
|
||||||
if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint');
|
if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint');
|
||||||
|
// 仅返回当前几何 changes,历史 entries 是整条修正时间线;两栈顺序沿用,不把上方 redo 检查变成实际重做。
|
||||||
return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) });
|
return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 有限预算的三次曲线命中精判。先用控制点包围盒排除,再用弦线距离与平坦度误差判断;
|
* 有限预算的三次曲线命中精判。先用控制点包围盒排除,再用弦线距离与平坦度误差判断;
|
||||||
* 不能确定时用 de Casteljau 二分。预算/数值精度不足明确返回 possible,不能伪称已证明 miss。
|
* 不能确定时用 de Casteljau 二分。预算/数值精度不足明确保留可能命中,不能伪称已证明 miss。
|
||||||
|
* 上层须提供合法曲线/点、非负半径和有效预算,本函数没有独立参数校验或 registry 检查。
|
||||||
*/
|
*/
|
||||||
import type {Point} from './types.ts';
|
import type {Point} from './types.ts';
|
||||||
import type {Cubic} from './geometry.ts';
|
import type {Cubic} from './geometry.ts';
|
||||||
@@ -16,12 +17,18 @@ function split([a,b,c,d]:Cubic):[Cubic,Cubic]{const ab=mid(a,b),bc=mid(b,c),cd=m
|
|||||||
* maxSteps 限总工作,maxDepth 限细分深度,error 控制可接受平坦度;stack 用显式 DFS。
|
* maxSteps 限总工作,maxDepth 限细分深度,error 控制可接受平坦度;stack 用显式 DFS。
|
||||||
* 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。
|
* 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。
|
||||||
* near 是到端点弦线段距离,flat 包括两控制点到该弦的偏差及 roundoff。
|
* near 是到端点弦线段距离,flat 包括两控制点到该弦的偏差及 roundoff。
|
||||||
* 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。 */
|
* 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。
|
||||||
|
* certainty 只在 hit=true 时区分 confirmed/possible;hit=false 的正常返回也固定带 possible,不是“可能没命中”的三态枚举。 */
|
||||||
export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){
|
export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){
|
||||||
|
// 栈中保存尚未证明可排除的子曲线,steps 按实际取出的子曲线计数;possible 记住已停止细分的模糊区域。
|
||||||
const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0;
|
const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0;
|
||||||
while(stack.length&&steps<options.maxSteps){const item=stack.pop()!;steps++;const xs=item.curve.map(p=>p.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue;
|
while(stack.length&&steps<options.maxSteps){const item=stack.pop()!;steps++;const xs=item.curve.map(p=>p.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue;
|
||||||
|
// 弦距离 near 与 flat 给出保守区间:near-flat 超半径可排除,near+flat 在半径内可确认。
|
||||||
|
// 数值非有限时直接返回 possible 且 budget=true;该标志此时不只是“栈还有工作”的含义。
|
||||||
const[a,b,c,d]=item.curve,roundoff=Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),...xs.map(Math.abs),...ys.map(Math.abs)),flat=Math.max(distance(b,a,d),distance(c,a,d))+roundoff,near=distance(point,a,d);if(!Number.isFinite(flat)||!Number.isFinite(near))return{hit:true,certainty:'possible' as const,steps,distance:Infinity,error:Infinity,budget:true};if(near-flat>radius)continue;if(near+flat<=radius)return{hit:true,certainty:'confirmed' as const,steps,distance:near,error:flat,budget:false};
|
const[a,b,c,d]=item.curve,roundoff=Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),...xs.map(Math.abs),...ys.map(Math.abs)),flat=Math.max(distance(b,a,d),distance(c,a,d))+roundoff,near=distance(point,a,d);if(!Number.isFinite(flat)||!Number.isFinite(near))return{hit:true,certainty:'possible' as const,steps,distance:Infinity,error:Infinity,budget:true};if(near-flat>radius)continue;if(near+flat<=radius)return{hit:true,certainty:'confirmed' as const,steps,distance:near,error:flat,budget:false};
|
||||||
|
// 达到平坦误差或深度阈值只保留 possible,budget 可仍为 false;继续二分时先压右再压左,按左优先 DFS。
|
||||||
if(flat<=options.error||item.depth>=options.maxDepth){possible=true;if(near<estimate){estimate=near;error=flat;}continue;}const[left,right]=split(item.curve);stack.push({curve:right,depth:item.depth+1},{curve:left,depth:item.depth+1});
|
if(flat<=options.error||item.depth>=options.maxDepth){possible=true;if(near<estimate){estimate=near;error=flat;}continue;}const[left,right]=split(item.curve);stack.push({curve:right,depth:item.depth+1},{curve:left,depth:item.depth+1});
|
||||||
}
|
}
|
||||||
|
// estimate/error 来自已记住的最小 near 模糊区,不是所有曲线的精确最近距离;全被排除时 estimate 仍是 Infinity。
|
||||||
return{hit:possible||stack.length>0,certainty:'possible' as const,steps,distance:estimate,error,budget:stack.length>0};
|
return{hit:possible||stack.length>0,certainty:'possible' as const,steps,distance:estimate,error,budget:stack.length>0};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
// Private shape provenance: values may still be mutable and require capture.
|
// Private shape provenance: values may still be mutable and require capture.
|
||||||
// Always copy; external Object.freeze is never accepted as ownership evidence.
|
// Always copy; external Object.freeze is never accepted as ownership evidence.
|
||||||
|
// WeakSet 以新容器身份为键,不按内容相等判断;容器不再被外部引用时,标记不会单独阻止回收。
|
||||||
const owned = new WeakSet<object>();
|
const owned = new WeakSet<object>();
|
||||||
/** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */
|
/** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */
|
||||||
export function dataArray<T>(values: Iterable<T>): readonly T[] {
|
export function dataArray<T>(values: Iterable<T>): readonly T[] {
|
||||||
const result = Object.freeze(Array.from(values)); owned.add(result); return result;
|
const result = Object.freeze(Array.from(values)); owned.add(result); return result;
|
||||||
}
|
}
|
||||||
/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。 */
|
/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。
|
||||||
|
* 对象展开会读取 getter 并保留嵌套值引用,也不保留原对象原型或属性描述符。 */
|
||||||
export function dataRecord<T extends object>(value: T): Readonly<T> {
|
export function dataRecord<T extends object>(value: T): Readonly<T> {
|
||||||
const result = Object.freeze({...value}); owned.add(result); return result;
|
const result = Object.freeze({...value}); owned.add(result); return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {repairConnectionOwners} from './structure.ts';
|
|||||||
import {clone,equal,fault,freeze} from './values.ts';
|
import {clone,equal,fault,freeze} from './values.ts';
|
||||||
|
|
||||||
/** Remove an owning canvas, retaining its entry and promoting only direct members. */
|
/** Remove an owning canvas, retaining its entry and promoting only direct members. */
|
||||||
/** 验证 canvas/入口生命周期,规划成员和引用转换;根不能解散,任何不支持路径整体拒绝。 */
|
/** 验证 canvas/入口生命周期并规划成员/引用转换;根不能解散,必需转换遇到不支持能力或格式时抛错。
|
||||||
|
* 源应是有效不可变文档;本函数不执行最终 validateDocument,也不调用宿主 policy 或直接发布。 */
|
||||||
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
|
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
|
||||||
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
|
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
|
||||||
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
|
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
|
||||||
@@ -23,7 +24,7 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
|
|||||||
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
|
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
|
||||||
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
|
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
|
||||||
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
|
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
|
||||||
// 即使最终用 changes 原子提交,也要向 editor 暴露逐成员 policy 检查,防止入口权限越权。
|
// commands 仅给 editor 逐成员做 policy 检查,不在此执行 element.move handler;几何转换直接由上面的坐标数学生成。
|
||||||
commands.push({type:'element.move',target,canvasId:parent});
|
commands.push({type:'element.move',target,canvasId:parent});
|
||||||
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
|
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
|
||||||
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
|
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
|
||||||
@@ -35,7 +36,8 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
|
|||||||
// 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。
|
// 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。
|
||||||
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
|
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
|
||||||
// Only declared canvas references have meaning here. Opaque payloads are retained.
|
// Only declared canvas references have meaning here. Opaque payloads are retained.
|
||||||
// 只解释已知组件明确声明的 canvas 引用;opaque payload 和普通字面 ID 文本保留。
|
// 扫描整个 promoted 文档中已知组件声明的 canvas 引用,因此子树外的引用也可能需要修正。
|
||||||
|
// opaque payload 与未声明的普通字面 ID 保留;引用路径复用 copy schema,但这里不执行复制的 external 策略。
|
||||||
for(const element of promoted.elements.values()){
|
for(const element of promoted.elements.values()){
|
||||||
if(registry.resolve(element)!=='known')continue;
|
if(registry.resolve(element)!=='known')continue;
|
||||||
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
|
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
|
||||||
@@ -48,12 +50,14 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
|
|||||||
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
|
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
|
||||||
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
|
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
|
||||||
}
|
}
|
||||||
// 仅产生实际改变的顶层 data 字段,并要求 content.edit 能力/宿主 policy 通过。
|
// 仅产生实际改变的顶层 data 字段;此处检查 content.edit 已安装,宿主 policy 由返回 commands 交给 editor 重查。
|
||||||
|
// 不调用 content.edit handler 生成变化,最终 schema/关系检查仍由事务发布路径执行。
|
||||||
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
|
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
|
||||||
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
|
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
|
||||||
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
|
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
|
||||||
commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
|
commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 保留原入口元素与边 ID;只移除 canvas 记录,不用 removalChanges 的子树删除语义。
|
||||||
return freeze({changes,commands});
|
return freeze({changes,commands});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ function documentEnvelope(document:Document):Document{const value=Object.freeze(
|
|||||||
export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document);
|
export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document);
|
||||||
|
|
||||||
/** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。
|
/** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。
|
||||||
* 不在此处验证整棵树或所有跨记录引用;完整发布前仍需 validateDocument。 */
|
* 不在此处验证整棵树、组件 schema 或所有跨记录引用;完整发布前仍需 validateDocument。
|
||||||
|
* canvas 总是复制冻结,element 可复用元素捕获模块已持有的记录;墓碑复制表成员,元素固有证明只省去重复形状遍历。 */
|
||||||
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
|
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
|
||||||
for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); }
|
for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); }
|
||||||
for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
|
for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
|
||||||
@@ -61,14 +62,19 @@ const staticElementProofs=new WeakSet<object>();
|
|||||||
/** 校验单条记录的基础形状、墓碑代次和已知组件数据。
|
/** 校验单条记录的基础形状、墓碑代次和已知组件数据。
|
||||||
* staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */
|
* staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */
|
||||||
export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void {
|
export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void {
|
||||||
|
// 静态证明仅随不可变记录身份复用,不能因浅冻结或一个文档 envelope 标记就跳过校验。
|
||||||
const proven=!!element&&staticElementProofs.has(element);
|
const proven=!!element&&staticElementProofs.has(element);
|
||||||
if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]);
|
if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]);
|
||||||
|
// 墓碑属于当前文档上下文,即使同一记录以前通过过形状校验,也要重新核对已死亡代次。
|
||||||
const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]);
|
const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]);
|
||||||
|
// 只有精确支持的组件版本进入 schema 检查;未知组件/不支持版本仍须通过通用形状约束,不在这里解释其 payload。
|
||||||
const definition=registry.components.get(element.componentId);
|
const definition=registry.components.get(element.componentId);
|
||||||
if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]);
|
if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]);
|
||||||
|
// 命中固有证明只能略过下方几何/端点形状遍历,不能略过上方墓碑与本次启用的 schema 检查。
|
||||||
if(proven)return;
|
if(proven)return;
|
||||||
if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);}
|
if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);}
|
||||||
else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
|
else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
|
||||||
|
// 拓扑规划可 componentData=false;此时仍可记固有证明,但以后启用 schema 的调用依然会重查 data。
|
||||||
if(hasOwnedElementData(element))staticElementProofs.add(element);
|
if(hasOwnedElementData(element))staticElementProofs.add(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +107,7 @@ function validateElementInDocument(document:Document,element:ElementRecord,regis
|
|||||||
function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator<void,void>{
|
function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator<void,void>{
|
||||||
if(!document.canvases.size)throw fault('missing_root');
|
if(!document.canvases.size)throw fault('missing_root');
|
||||||
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
|
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
|
||||||
|
// 一次遍历累计入口占用和根数;必须继续到最后,才能得到根数及所有元素的完整结论。
|
||||||
const entries=new Set<Id>();let roots=0;
|
const entries=new Set<Id>();let roots=0;
|
||||||
for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);}
|
for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);}
|
||||||
if(roots!==1)throw fault('invalid_roots');
|
if(roots!==1)throw fault('invalid_roots');
|
||||||
@@ -111,7 +118,8 @@ function* validateDocumentChecks(document:Document,registry:Registry,componentDa
|
|||||||
export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);}
|
export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);}
|
||||||
/** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */
|
/** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */
|
||||||
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);}
|
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);}
|
||||||
/** 同步完整校验,要求恰好一个根,所有拥有关系、端点和组件数据均合法。 */
|
/** 同步走完整文档检查:要求一个根,检查拥有关系、入口、节点端点及连线共同 owner。
|
||||||
|
* 已支持的精确组件版本检查 data schema;未知/不支持版本按通用记录规则保留,不冒称已理解其 payload。 */
|
||||||
export function validateDocument(document:Document,registry:Registry):void{
|
export function validateDocument(document:Document,registry:Registry):void{
|
||||||
if(!document.canvases.size)throw fault('missing_root');
|
if(!document.canvases.size)throw fault('missing_root');
|
||||||
for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation);
|
for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation);
|
||||||
@@ -120,21 +128,24 @@ export function validateDocument(document:Document,registry:Registry):void{
|
|||||||
if(roots!==1)throw fault('invalid_roots');
|
if(roots!==1)throw fault('invalid_roots');
|
||||||
for(const element of document.elements.values())validateElementInDocument(document,element,registry,true);
|
for(const element of document.elements.values())validateElementInDocument(document,element,registry,true);
|
||||||
}
|
}
|
||||||
/** 一批有序修改及其允许的 canvas 上下文;回放不能跨越已冻结的操作范围。 */
|
/** 一批有序修改及可选 canvas 上下文;当前执行器用它检查 field 目标的 owner。
|
||||||
|
* 这不是覆盖所有 change 种类的授权器;整元素/树修改还需调用方规划、权限及发布前校验。 */
|
||||||
export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]};
|
export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]};
|
||||||
/** 在私有表副本中应用一批变化;默认校验 before 条件,失败不会修改输入文档。 */
|
/** 在已捕获的不可变文档上,以私有表副本应用变化;默认校验 before 条件。
|
||||||
|
* 未改记录沿用原引用,不是任意外部 Document 的深隔离入口;修改记录的 freeze 也可能触及共享嵌套值。 */
|
||||||
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
|
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
|
||||||
return applyChangeBatches(document,[{changes}],conditional,false);
|
return applyChangeBatches(document,[{changes}],conditional,false);
|
||||||
}
|
}
|
||||||
/** Ordered conditional replay against one private draft; no intermediate document escapes. */
|
/** Ordered conditional replay against one private draft; no intermediate document escapes. */
|
||||||
/** 顺序回放多批变化,已经满足的 after 可跳过,但仍检查目标的 scope。 */
|
/** 顺序回放多批变化,已经满足的 after 可跳过;field 变化在跳过前仍检查当前 owner 范围。
|
||||||
|
* 批次未提供 canvasIds 或提供空数组时,这一局部 owner 范围检查不启用。 */
|
||||||
export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document {
|
export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document {
|
||||||
return applyChangeBatches(document,batches,true,true);
|
return applyChangeBatches(document,batches,true,true);
|
||||||
}
|
}
|
||||||
/** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。
|
/** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。
|
||||||
* 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */
|
* 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */
|
||||||
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document {
|
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document {
|
||||||
// 复制表成员而复用未改记录;出错时丢弃这些私有 Map,输入快照保持不变。
|
// 复制表成员而复用未改记录;失败不返回私有 Map。对有效不可变输入,不会发布或改写原快照。
|
||||||
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
|
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
|
||||||
for (const batch of batches) for (const change of batch.changes) {
|
for (const batch of batches) for (const change of batch.changes) {
|
||||||
if(replay){
|
if(replay){
|
||||||
@@ -158,7 +169,8 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
|
|||||||
if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
|
if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
|
||||||
elements.set(element.id, freeze({ ...element, data }));
|
elements.set(element.id, freeze({ ...element, data }));
|
||||||
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord);
|
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord);
|
||||||
// 整元素修改必须匹配 before;删除同时保留墓碑,使旧 incarnation 永远不能复活。
|
// 整元素修改在 conditional=true 时匹配 before;删除只记录已死亡的最大代次。
|
||||||
|
// 此执行器不直接以墓碑拒绝后续插入,发布前 validateElementRecord/validateDocument 才拦截旧代次。
|
||||||
} else if (change.kind === 'element') {
|
} else if (change.kind === 'element') {
|
||||||
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
|
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
|
||||||
if (change.after) {
|
if (change.after) {
|
||||||
@@ -184,6 +196,7 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
|
|||||||
}
|
}
|
||||||
/** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */
|
/** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */
|
||||||
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean {
|
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean {
|
||||||
|
// canvas/element 使用整记录 after 比较;删除后的缺失等同于 after:null,重复删除可跳过。
|
||||||
if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after);
|
if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after);
|
||||||
if(change.kind==='element')return equal(elements.get(change.id)??null,change.after);
|
if(change.kind==='element')return equal(elements.get(change.id)??null,change.after);
|
||||||
const element=elements.get(change.target.id);
|
const element=elements.get(change.target.id);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射,
|
* 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射,
|
||||||
* 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。
|
* 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。
|
||||||
* 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。
|
* 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。
|
||||||
|
* 构造器仍同步展开源表并校验保存状态,step 也没有按字节/毫秒强制切断单记录转换。
|
||||||
*/
|
*/
|
||||||
import {detachJsonData} from './json-data.ts';
|
import {detachJsonData} from './json-data.ts';
|
||||||
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
|
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
|
||||||
@@ -22,7 +23,8 @@ type Immutable<T>=T extends object?{readonly [K in keyof T]:Immutable<T[K]>}:T;
|
|||||||
export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
|
export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
|
||||||
/** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */
|
/** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */
|
||||||
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id);
|
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id);
|
||||||
/** 校验外部检查点格式、阶段/计数/去重与前缀边界;与实际源的一致性在工厂中进一步核验。 */
|
/** 检查 DTO 中明确列出的格式、阶段/计数、去重与前缀边界;不是按源重新推导整个已保存执行轨迹。
|
||||||
|
* 与实际源的部分成员/映射一致性在工厂核验;可信持久记录与 token 来源由 runtime 边界保证。 */
|
||||||
export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
|
export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
|
||||||
if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable');
|
if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable');
|
||||||
if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint');
|
if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint');
|
||||||
@@ -33,7 +35,8 @@ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
|
|||||||
if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint');
|
if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint');
|
||||||
}
|
}
|
||||||
/** 创建或恢复解释器;绑定 project/sourceToken/schemaToken/选区/偏移/预留映射,任一不符拒绝续跑。
|
/** 创建或恢复解释器;绑定 project/sourceToken/schemaToken/选区/偏移/预留映射,任一不符拒绝续跑。
|
||||||
* token 的真实源摘要/定义签名由调用方提供并验证,这里不读取 Blob 或计算 SHA。 */
|
* token 的真实源摘要/定义签名由调用方提供并验证,这里只比较字符串,不读取 Blob 或计算 SHA。
|
||||||
|
* 绑定匹配不等于任意外来检查点已获防篡改证明;范围/阶段状态应来自可信恢复记录。 */
|
||||||
export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){
|
export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){
|
||||||
const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined;
|
const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined;
|
||||||
// 从检查点复制出私有可变 state,旧 checkpoint 保持不变;新任务从 selection 开始。
|
// 从检查点复制出私有可变 state,旧 checkpoint 保持不变;新任务从 selection 开始。
|
||||||
@@ -56,7 +59,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
|
|||||||
const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[];
|
const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[];
|
||||||
// 这些 Map 是派生输出缓存,overlay 内部可读其增长,不作为第二份发布文档。
|
// 这些 Map 是派生输出缓存,overlay 内部可读其增长,不作为第二份发布文档。
|
||||||
const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[];
|
const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[];
|
||||||
// 源校验每次重开重新执行;topologyOnly 仅用于明确的结构源,最终输出仍执行完整数据校验。
|
// 源校验每次重开重新执行;topologyOnly 跳过源的组件 payload schema。
|
||||||
|
// 本解释器继续到 result 时校验源+副本完整输出;若 runtime 在 ready 接管逐记录生成,则由那条消费路径完成后续校验。
|
||||||
let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false;
|
let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false;
|
||||||
/** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */
|
/** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */
|
||||||
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;};
|
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;};
|
||||||
@@ -69,7 +73,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
|
|||||||
/** 只为尚未分配的源项取新 ID;优先宿主预留值,used 同时覆盖源、墓碑与全部新值。 */
|
/** 只为尚未分配的源项取新 ID;优先宿主预留值,used 同时覆盖源、墓碑与全部新值。 */
|
||||||
function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;}
|
function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 源已校验且刚到 emit,范围/映射固定,可以交给运行时逐记录转换,不要求完整计划已生成。 */
|
/** 源检查迭代已结束、阶段刚到 emit 且 emitted=0 时可接管范围/映射;不是完整计划已生成或已加入画布。
|
||||||
|
* topologyOnly 下“源检查结束”仍只证明拓扑路径通过,不证明所有组件 payload schema。 */
|
||||||
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
|
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
|
||||||
/** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */
|
/** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */
|
||||||
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});},
|
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});},
|
||||||
@@ -98,7 +103,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
|
|||||||
if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};}
|
if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};}
|
||||||
else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break;
|
else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break;
|
||||||
}
|
}
|
||||||
// 扫描上级入边与范围内边;随后 element/canvas 分配都把新 ID 写回 state,恢复可重用。
|
// 与同步规划器相同:显式选边、复制 canvas 的边及指向复制节点的入边;只有源端被复制不足以自动带上出边。
|
||||||
|
// 随后 element/canvas 分配都把新 ID 写回 state,恢复重用已有分配,不为同一前缀再造身份。
|
||||||
case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break;
|
case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break;
|
||||||
case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
|
case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
|
||||||
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break;
|
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break;
|
||||||
@@ -110,7 +116,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
|
|||||||
// 每次状态机工作/阶段切换单调计数;step 的 wall time 和 yield 时机由宿主软预算决定。
|
// 每次状态机工作/阶段切换单调计数;step 的 wall time 和 yield 时机由宿主软预算决定。
|
||||||
state.steps++;return false;
|
state.steps++;return false;
|
||||||
},
|
},
|
||||||
/** 仅 done 且本轮最终校验成功后返回完整冻结计划;不能把未完成缓存当成可提交计划。 */
|
/** 仅 done 且本轮最终校验成功后返回完整冻结计划;不能把未完成缓存当成可提交计划。
|
||||||
|
* 返回的是准备 changes,不是 editor 提交/持久化/服务端确认结果;调用 result 也不会发网络请求。 */
|
||||||
result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});},
|
result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。
|
* 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。
|
||||||
* 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。
|
* 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。
|
||||||
* 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。
|
* 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。
|
||||||
|
* 源须在规划期间保持不可变;资源 ID 沿 data/copy 声明处理,不在这里复制文件字节或创建资源绑定。
|
||||||
*/
|
*/
|
||||||
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
|
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
|
||||||
import { copySlots } from './copy-paths.ts';
|
import { copySlots } from './copy-paths.ts';
|
||||||
@@ -20,6 +21,7 @@ export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffs
|
|||||||
export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, document: Document): Data {
|
export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, document: Document): Data {
|
||||||
const definition = registry.components.get(element.componentId), schema = definition?.copy;
|
const definition = registry.components.get(element.componentId), schema = definition?.copy;
|
||||||
if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]);
|
if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]);
|
||||||
|
// data 完整 JSON 复制后再改声明路径;未声明为引用的字符串(即使看起来像 ID)原样保留。
|
||||||
const data = clone(element.data) as Record<string, Json>;
|
const data = clone(element.data) as Record<string, Json>;
|
||||||
for (const ref of schema.references) {
|
for (const ref of schema.references) {
|
||||||
for (const slot of copySlots(data, ref.path)) {
|
for (const slot of copySlots(data, ref.path)) {
|
||||||
@@ -37,13 +39,15 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen
|
|||||||
slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value);
|
slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// omit 删除对象字段,拒绝删除数组下标造成稀疏数据;生成任务状态通过声明省略,不复制成新任务。
|
// omit 只删除声明的对象字段,数组下标不能直接删成空洞;是否清除任务状态取决于该组件 copy.omit,
|
||||||
|
// 规划器本身不识别所有插件的任务字段,也不把复制出的配置当成启动生成命令。
|
||||||
for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; }
|
for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; }
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */
|
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */
|
||||||
/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。 */
|
/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。
|
||||||
|
* yield 点不是字节/耗时硬上限,单条记录的 clone、引用转换或祖先遍历仍可能较重。 */
|
||||||
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
|
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
|
||||||
yield* validateDocumentSteps(document, registry);
|
yield* validateDocumentSteps(document, registry);
|
||||||
const offset = options.worldOffset ?? { x: 32, y: 32 };
|
const offset = options.worldOffset ?? { x: 32, y: 32 };
|
||||||
@@ -78,6 +82,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。
|
// 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。
|
||||||
|
// 仅 source 被复制、target 在外部的出边,不因 source 在范围内就自动纳入;显式选中等其它条件仍可纳入。
|
||||||
const edges: ElementRecord[] = [];
|
const edges: ElementRecord[] = [];
|
||||||
for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } }
|
for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } }
|
||||||
// ID 分配前先保留源元素/canvas/墓碑全部 ID;同一 used 集合还防止两种新 ID 互撞。
|
// ID 分配前先保留源元素/canvas/墓碑全部 ID;同一 used 集合还防止两种新 ID 互撞。
|
||||||
@@ -124,6 +129,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
|
|||||||
/** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。
|
/** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。
|
||||||
* 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */
|
* 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */
|
||||||
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
|
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
|
||||||
|
// 调用方须提供已固定且覆盖范围的映射;此转换器不自行分配缺失 ID,canvas 分支依赖非空 parent、只供子 canvas 使用。
|
||||||
if (kind === 'canvas') {
|
if (kind === 'canvas') {
|
||||||
const canvas=record as CanvasRecord,parent=canvas.parent!;
|
const canvas=record as CanvasRecord,parent=canvas.parent!;
|
||||||
const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
|
const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
|
||||||
@@ -140,9 +146,11 @@ export function duplicateTreeChange(document:Document,registry:Registry,kind:'el
|
|||||||
return { kind: 'element', id: after.id, before: null, after };
|
return { kind: 'element', id: after.id, before: null, after };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。 */
|
/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。
|
||||||
|
* 端点结构与组件定义的 copy.references 是两套路径:前者在此映射,后者交给 remapCopyData。 */
|
||||||
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
|
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
|
||||||
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
|
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
|
||||||
|
// 与 convertPath 的同变换早返回不同,这里即使 owner 空间不变,也先要求复制支持该曲线格式。
|
||||||
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
|
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
|
||||||
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
|
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
|
||||||
const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner);
|
const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner);
|
||||||
@@ -155,7 +163,8 @@ export function duplicateEdgeChange(document:Document,registry:Registry,edge:Ele
|
|||||||
const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 });
|
const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 });
|
||||||
return { x: to.x - from.x, y: to.y - from.y };
|
return { x: to.x - from.x, y: to.y - from.y };
|
||||||
};
|
};
|
||||||
// knot 按链中序号在两端位移间插值;一端仍为原节点时,不把整条线简单平移。
|
// knot 按序号 t=(index+1)/(count+1) 在线两端世界位移间插值,不按弧长或几何距离加权。
|
||||||
|
// 这里只移动 knot.position,in/out 等偏移向量已在前面的空间转换中处理,不再次加位移。
|
||||||
const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots;
|
const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots;
|
||||||
path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record<string, Json>, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) };
|
path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record<string, Json>, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) };
|
||||||
}
|
}
|
||||||
@@ -167,7 +176,8 @@ export function duplicateOverlay(document:Document,elements:ReadonlyMap<Id,Eleme
|
|||||||
|
|
||||||
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
|
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
|
||||||
/** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。
|
/** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。
|
||||||
* additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。 */
|
* additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。
|
||||||
|
* 本函数不验证 base/additions 的 ID 不相交;规划器分配时保证此约定,否则 size/迭代会出现重复。 */
|
||||||
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
|
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
|
||||||
const view: ReadonlyMap<K,V> = {
|
const view: ReadonlyMap<K,V> = {
|
||||||
// get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。
|
// get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。
|
* 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。
|
||||||
* 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。
|
* 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。
|
||||||
* 修改入口共用 entered 防重入和 disposed 生命周期检查;协调提案另用 rootRevision 拒绝过期计划。
|
* 命令/协调发布入口共用 entered 防重入和 disposed 检查;协调提案另用 rootRevision 拒绝过期计划。
|
||||||
|
* dispose/subscribe 和只读查询有各自生命周期规则,entered 是同步重入闸门,不是跨线程或网络锁。
|
||||||
*/
|
*/
|
||||||
import {dataArray,dataRecord} from './data-containers.ts';
|
import {dataArray,dataRecord} from './data-containers.ts';
|
||||||
import {stageCreation,materializeCreations} from './creation-draft.ts';
|
import {stageCreation,materializeCreations} from './creation-draft.ts';
|
||||||
@@ -29,9 +30,13 @@ export type EditorOptions<Runtime = undefined> = {
|
|||||||
policy?: (command: Command, document: Document) => Availability;
|
policy?: (command: Command, document: Document) => Availability;
|
||||||
/** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */
|
/** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */
|
||||||
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
|
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
|
||||||
|
/** listener 抛错后的诊断回调;诊断本身失败也不能回滚已经发布的状态。 */
|
||||||
onListenerError?: (error: unknown) => void;
|
onListenerError?: (error: unknown) => void;
|
||||||
|
/** 明确开放 changes.apply 给可信宿主;仍执行 policy 和发布前文档检查。 */
|
||||||
allowRawChanges?: boolean;
|
allowRawChanges?: boolean;
|
||||||
|
/** 恢复层预先校验的核心快照;初始化直接沿用其中 history/operations,不在此完整解码恢复 DTO。 */
|
||||||
recovery?: CoreSnapshot & { readonly rootRevision: number };
|
recovery?: CoreSnapshot & { readonly rootRevision: number };
|
||||||
|
/** 与初始核心快照配套的宿主 runtime;其内部不可变性由宿主负责。 */
|
||||||
runtime?: Runtime;
|
runtime?: Runtime;
|
||||||
/** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */
|
/** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */
|
||||||
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
|
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
|
||||||
@@ -58,17 +63,20 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
// 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。
|
// 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。
|
||||||
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries;
|
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries;
|
||||||
let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget));
|
let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget));
|
||||||
// WeakMap 按确切快照身份保存查询,既拒绝错误快照,也不永久保留已废弃文档。
|
// WeakMap 按 Document 对象身份保存查询,不按整个 Root 身份;同文档的 runtime-only 发布会更新这条关联。
|
||||||
|
// 旧 Document 尚有外部引用时可查旧索引;未登记的外来 Document 明确拒绝,WeakMap 不独自保活旧文档。
|
||||||
const queriesByDocument=new WeakMap([[document,queries]]);
|
const queriesByDocument=new WeakMap([[document,queries]]);
|
||||||
// entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。
|
// entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。
|
||||||
let entered = false, disposed = false, sequence = 0;
|
let entered = false, disposed = false, sequence = 0;
|
||||||
for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence);
|
for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence);
|
||||||
const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>();
|
const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>();
|
||||||
|
// createId 可能在最终发布前被调用;失败的候选不会回收已经向宿主申请过的 ID。
|
||||||
const createId = options.createId || (() => globalThis.crypto.randomUUID());
|
const createId = options.createId || (() => globalThis.crypto.randomUUID());
|
||||||
const limit = options.historyLimit ?? 100;
|
const limit = options.historyLimit ?? 100;
|
||||||
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit');
|
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit');
|
||||||
|
|
||||||
/** 命令→候选文档/changes 的纯规划;不创建历史、不发送请求,也不改当前 state。 */
|
/** 命令→候选文档/changes 的同步规划,不创建历史、不发送请求,也不替换当前 state。
|
||||||
|
* policy/handler 由宿主提供,须遵守无外部写入约定;本函数不能回滚插件自己的副作用。 */
|
||||||
function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } {
|
function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } {
|
||||||
const permission = options.policy?.(command, document);
|
const permission = options.policy?.(command, document);
|
||||||
if (permission && !permission.enabled) throw fault(permission.code, permission.targets);
|
if (permission && !permission.enabled) throw fault(permission.code, permission.targets);
|
||||||
@@ -107,11 +115,13 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
|
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
|
||||||
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
|
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
|
||||||
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
|
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
|
||||||
|
// 端点命令只要求 connection.edit 已安装,直接生成 source/target 变化,不调用该 handler。
|
||||||
} else if(command.type==='connection.endpoints'){
|
} else if(command.type==='connection.endpoints'){
|
||||||
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
|
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
|
||||||
changes=[];
|
changes=[];
|
||||||
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
|
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
|
||||||
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||||
|
// 删除使用结构规划器收集子树/附边,不通过单一内容或几何 handler。
|
||||||
} else if (command.type === 'element.remove') {
|
} else if (command.type === 'element.remove') {
|
||||||
changes = removalChanges(document,command.target,queriesByDocument.get(document));
|
changes = removalChanges(document,command.target,queriesByDocument.get(document));
|
||||||
// 跨 owner 移动需要同时处理归属、几何、视觉单位、入口子 canvas 与相关边。
|
// 跨 owner 移动需要同时处理归属、几何、视觉单位、入口子 canvas 与相关边。
|
||||||
@@ -142,7 +152,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
if (command.type === 'element.data') run('content.edit', command.patch);
|
if (command.type === 'element.data') run('content.edit', command.patch);
|
||||||
else {
|
else {
|
||||||
if (element.structure !== 'node' || Object.keys(command.patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]);
|
if (element.structure !== 'node' || Object.keys(command.patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]);
|
||||||
// 混合几何 patch 按 move→resize→rotate 分发,各 handler 只能处理自己的字段。
|
// 按输入字段拆成 move→resize→rotate;上方检查输出目标与 geometry 字段,具体几何语义仍由 handler 实现。
|
||||||
const parts: [Capability, Partial<Geometry>][] = [
|
const parts: [Capability, Partial<Geometry>][] = [
|
||||||
['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))],
|
['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))],
|
||||||
['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))],
|
['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))],
|
||||||
@@ -153,7 +163,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
changes = output;
|
changes = output;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 创建可先放入临时 draft 容器,整批结束统一物化,减少大量创建时的重复表复制。
|
// 创建先放入临时 draft 容器,整批结束统一物化;其它修改沿有序 changes 应用,返回的变化另做 JSON 捕获。
|
||||||
const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes);
|
const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes);
|
||||||
return { document: next, changes: freeze(clone(changes)) };
|
return { document: next, changes: freeze(clone(changes)) };
|
||||||
}
|
}
|
||||||
@@ -170,7 +180,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
if(isThenable(runtime))throw fault('async_transaction');
|
if(isThenable(runtime))throw fault('async_transaction');
|
||||||
return runtime===next.runtime?next:Object.freeze({...next,runtime});
|
return runtime===next.runtime?next:Object.freeze({...next,runtime});
|
||||||
}
|
}
|
||||||
/** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。 */
|
/** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。
|
||||||
|
* 本轮遍历 listener 集合的数组副本,通知期间退订不撤回副本中尚未调用的 listener。 */
|
||||||
function emit(commit: Commit) {
|
function emit(commit: Commit) {
|
||||||
for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } }
|
for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } }
|
||||||
}
|
}
|
||||||
@@ -199,7 +210,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
history = Object.freeze({ ...history, entries: readMap(entries) });
|
history = Object.freeze({ ...history, entries: readMap(entries) });
|
||||||
}
|
}
|
||||||
if (outbound) {
|
if (outbound) {
|
||||||
// 收集旧/新 owner、父 canvas 和端点 owner,形成跨图版本/claim 的完整参与者范围。
|
// 按变化种类收集参与者:canvas 自身及父项、元素旧/新 owner、新建边或端点变化涉及的端点 owner。
|
||||||
|
// 这是出站意图的 canvasIds 来源,不是一次对所有可能插件引用的全图扫描。
|
||||||
const canvasIds = new Set<Id>();
|
const canvasIds = new Set<Id>();
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (change.kind === 'canvas') {
|
if (change.kind === 'canvas') {
|
||||||
@@ -239,6 +251,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
}
|
}
|
||||||
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
|
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
|
||||||
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
|
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
|
||||||
|
// reducer/prepareRuntime/查询构建都在 state 赋值前完成;拒绝只丢弃候选,不能撤回宿主钩子的外部副作用。
|
||||||
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
|
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
|
||||||
if (isThenable(runtime)) throw fault('async_transaction');
|
if (isThenable(runtime)) throw fault('async_transaction');
|
||||||
// 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。
|
// 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。
|
||||||
@@ -249,14 +262,15 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
|
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同步事务:多条 dispatch 共享 draft,任一命令失败使整批拒绝;回调结束后 tx 关闭。 */
|
/** 同步事务:多条 dispatch 共享 draft,正常 Error 失败会阻止发布;回调结束后 tx 关闭。
|
||||||
|
* failed 是 unknown 但当前分支用真值判断;宿主 policy/handler 应抛 Error,不应依赖抛 null/false/0 的拒绝语义。 */
|
||||||
function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result {
|
function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result {
|
||||||
try { assertOpen(); } catch (error) { return reject(error); }
|
try { assertOpen(); } catch (error) { return reject(error); }
|
||||||
entered = true;
|
entered = true;
|
||||||
let active = true, document = state.document, failed: unknown;
|
let active = true, document = state.document, failed: unknown;
|
||||||
const changes: Change[] = [];
|
const changes: Change[] = [];
|
||||||
const tx: Transaction = Object.freeze({
|
const tx: Transaction = Object.freeze({
|
||||||
/** 私有 dispatch 记住第一次失败,后续命令不再继续推进 draft。 */
|
/** 私有 dispatch 记录规划抛错;常规 Error 被 failed 真值分支记住,后续命令停止推进。 */
|
||||||
dispatch(command: Command): Availability {
|
dispatch(command: Command): Availability {
|
||||||
if (!active) throw fault('transaction_closed');
|
if (!active) throw fault('transaction_closed');
|
||||||
if (failed) return { enabled: false, ...reject(failed) };
|
if (failed) return { enabled: false, ...reject(failed) };
|
||||||
@@ -277,6 +291,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
// 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。
|
// 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。
|
||||||
document=materializeCreations(document);
|
document=materializeCreations(document);
|
||||||
validateDocument(document, options.registry);
|
validateDocument(document, options.registry);
|
||||||
|
// 无变化或表内容/迭代顺序与原文档相同,直接报告 applied;不分配历史/操作 ID,也不通知订阅者。
|
||||||
if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
|
if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
|
||||||
let history = state.history, historyEntryId: Id | undefined;
|
let history = state.history, historyEntryId: Id | undefined;
|
||||||
if (settings.history !== false && (!settings.origin || settings.origin === 'user')) {
|
if (settings.history !== false && (!settings.origin || settings.origin === 'user')) {
|
||||||
@@ -292,10 +307,11 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
|
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
|
||||||
undo.push(historyEntryId);
|
undo.push(historyEntryId);
|
||||||
}
|
}
|
||||||
// 容量淘汰移除最旧 entry;资源 pin 的后续扫描以更新后的 history 为准。
|
// 仅从当前 history 淘汰最旧 entry;未解决操作的 historyTransition 等证据仍可能 pin 住旧资源。
|
||||||
while (undo.length > limit) entries.delete(undo.shift()!);
|
while (undo.length > limit) entries.delete(undo.shift()!);
|
||||||
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) });
|
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) });
|
||||||
}
|
}
|
||||||
|
// history:false 或非 user origin 可不建历史项,但 outbound 由 publish 的 origin 规则独立决定。
|
||||||
return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId);
|
return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId);
|
||||||
} catch (error) { active = false; return reject(error); }
|
} catch (error) { active = false; return reject(error); }
|
||||||
finally { entered = false; }
|
finally { entered = false; }
|
||||||
@@ -304,7 +320,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
|
function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
|
||||||
return transaction(tx => { tx.dispatch(command); }, settings);
|
return transaction(tx => { tx.dispatch(command); }, settings);
|
||||||
}
|
}
|
||||||
/** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。 */
|
/** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。
|
||||||
|
* can 只检查 disposed,不占 entered;宿主提供的 policy/handler 可能仍被调用,不应有外部写入。 */
|
||||||
function can(command: Command): Availability {
|
function can(command: Command): Availability {
|
||||||
try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; }
|
try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; }
|
||||||
catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||||
@@ -319,6 +336,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
const entry = state.history.entries.get(id)!;
|
const entry = state.history.entries.get(id)!;
|
||||||
const allowed = options.traversalPolicy?.(entry, direction);
|
const allowed = options.traversalPolicy?.(entry, direction);
|
||||||
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
|
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
|
||||||
|
// undo 反转变化顺序和值,redo 使用正向变化;私有执行先通过最终文档校验,才构造新的两栈。
|
||||||
const planned=direction==='undo'?invert(entry.changes):entry.changes;
|
const planned=direction==='undo'?invert(entry.changes):entry.changes;
|
||||||
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry);
|
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry);
|
||||||
const entries=new Map(state.history.entries);
|
const entries=new Map(state.history.entries);
|
||||||
@@ -326,6 +344,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
// Frozen operations retain their original identities and incarnations.
|
// Frozen operations retain their original identities and incarnations.
|
||||||
// 显式恢复的生命周期影响可继续遍历的历史目标;已冻结操作身份不在这里改写。
|
// 显式恢复的生命周期影响可继续遍历的历史目标;已冻结操作身份不在这里改写。
|
||||||
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
|
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
|
||||||
|
// 当前 entry 仍存“可重做的正向 changes”;undo 后把实际逆向变化再 invert,保留新恢复代次。
|
||||||
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))})));
|
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))})));
|
||||||
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
|
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
|
||||||
return publish(document, changes, direction, history, id);
|
return publish(document, changes, direction, history, id);
|
||||||
@@ -335,7 +354,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
// 公开只读快照与绑定查询;指定未知 document 查查询会拒绝,不隐式混用当前索引。
|
// 公开只读快照与绑定查询;指定未知 document 查查询会拒绝,不隐式混用当前索引。
|
||||||
return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can,
|
return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can,
|
||||||
/** Coordinator entry: document, amended history and outbound fact share one Root. */
|
/** Coordinator entry: document, amended history and outbound fact share one Root. */
|
||||||
/** 异步图片尺寸解析只提交仍匹配 Root/目标的计划,并修正原创建历史,不额外造用户项。 */
|
/** 接收异步解码后的尺寸,入口本身同步;匹配 Root/目标后修正原创建历史,不额外造用户项。
|
||||||
|
* asset-resolution 仍可产生出站 operation,并与原创建 entry 关联,不是完全跳过 history/runtime。 */
|
||||||
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
|
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
|
||||||
try { assertOpen(); } catch (error) { return reject(error); }
|
try { assertOpen(); } catch (error) { return reject(error); }
|
||||||
entered = true;
|
entered = true;
|
||||||
@@ -363,6 +383,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
|
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
|
||||||
const runtime = reduce(state.runtime);
|
const runtime = reduce(state.runtime);
|
||||||
if (isThenable(runtime)) throw fault('async_transaction');
|
if (isThenable(runtime)) throw fault('async_transaction');
|
||||||
|
// 相同 runtime 引用视为 no-op,prepareRuntime 不执行;调用方须返回新对象而非原地改旧状态。
|
||||||
if (runtime !== state.runtime) {
|
if (runtime !== state.runtime) {
|
||||||
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
|
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
|
||||||
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
|
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
|
||||||
@@ -373,7 +394,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
finally { entered = false; }
|
finally { entered = false; }
|
||||||
},
|
},
|
||||||
/** Engine-only canonical proposal: validation and runtime state publish as one root. */
|
/** Engine-only canonical proposal: validation and runtime state publish as one root. */
|
||||||
/** 将已校验的 ACK/恢复提案与 runtime 一起发布;旧 rootRevision 提案明确拒绝。
|
/** 接收 ACK/恢复提案并重新捕获校验文档,与 runtime 一起发布;旧 rootRevision 提案明确拒绝。
|
||||||
* 可替换 history/operations,但不允许改写原有操作身份;文档实际变化才推进 revision。 */
|
* 可替换 history/operations,但不允许改写原有操作身份;文档实际变化才推进 revision。 */
|
||||||
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result {
|
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result {
|
||||||
try { assertOpen(); } catch (error) { return reject(error); }
|
try { assertOpen(); } catch (error) { return reject(error); }
|
||||||
@@ -384,7 +405,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
|
const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
|
||||||
validateDocument(next, options.registry);
|
validateDocument(next, options.registry);
|
||||||
let nextSequence=sequence;
|
let nextSequence=sequence;
|
||||||
// 恢复替换必须让两栈精确覆盖 entry,entry 的 operation ID 必须存在,序列保持单调。
|
// 替换时要求两栈精确覆盖 entries、历史关联的 operation ID 存在,已有操作内容完全相等。
|
||||||
|
// 新操作的 sequence 在此仅检查正安全整数,再取最大值供后续分配;完整恢复校验由宿主边界完成。
|
||||||
if(replacement){
|
if(replacement){
|
||||||
const {history,operations}=replacement,ids=[...history.undo,...history.redo];
|
const {history,operations}=replacement,ids=[...history.undo,...history.redo];
|
||||||
if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!history.entries.has(id)))throw fault('invalid_history');
|
if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!history.entries.has(id)))throw fault('invalid_history');
|
||||||
@@ -392,6 +414,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);}
|
for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);}
|
||||||
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history');
|
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history');
|
||||||
}
|
}
|
||||||
|
// 即使文档相等,reconcile 仍发布一个新 rootRevision 与 ACK 通知;仅文档变化推进 revision。
|
||||||
const changed = !documentsEqual(next, state.document);
|
const changed = !documentsEqual(next, state.document);
|
||||||
const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries);
|
const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries);
|
||||||
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
|
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
|
||||||
@@ -407,7 +430,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
|||||||
dispose() { disposed = true; listeners.clear(); },
|
dispose() { disposed = true; listeners.clear(); },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/** 比较规范化表,先做身份/大小快判;无净变化的事务不增加 revision 或历史。 */
|
/** 比较表的迭代顺序与内容,先做身份/大小快判;不是忽略插入顺序的 Map 集合等价。
|
||||||
|
* transaction 用它省去重复发布,reconcile 用它决定是否推进文档 revision。 */
|
||||||
function documentsEqual(a: Document, b: Document): boolean {
|
function documentsEqual(a: Document, b: Document): boolean {
|
||||||
if(a===b)return true;
|
if(a===b)return true;
|
||||||
if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false;
|
if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {detachJsonData} from './json-data.ts';
|
|||||||
// Provenance comes from detaching JSON data, never from an external freeze or
|
// Provenance comes from detaching JSON data, never from an external freeze or
|
||||||
// document envelope. This does not assert record validity or registry support.
|
// document envelope. This does not assert record validity or registry support.
|
||||||
const owned=new WeakSet<object>();
|
const owned=new WeakSet<object>();
|
||||||
/** 已由本模块捕获的记录直接复用;外部记录重新复制冻结,再标记新记录身份。 */
|
/** 已由本模块捕获的记录直接复用;外部记录重新按 JSON 语义复制冻结,再标记新记录身份。
|
||||||
|
* 此处不调用组件 schema;捕获成功与记录合法是两项独立事实。 */
|
||||||
export function detachElementData(record:ElementRecord):ElementRecord{
|
export function detachElementData(record:ElementRecord):ElementRecord{
|
||||||
if(record&&typeof record==='object'&&owned.has(record))return record;
|
if(record&&typeof record==='object'&&owned.has(record))return record;
|
||||||
const value=detachJsonData(record);
|
const value=detachJsonData(record);
|
||||||
@@ -21,7 +22,8 @@ export const hasOwnedElementData=(record:ElementRecord)=>!!record&&typeof record
|
|||||||
// Framework-owned validators depend only on detached immutable data. Custom
|
// Framework-owned validators depend only on detached immutable data. Custom
|
||||||
// validators remain live, including their method receiver and external context.
|
// validators remain live, including their method receiver and external context.
|
||||||
const builtinValidators=new WeakSet<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>();
|
const builtinValidators=new WeakSet<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>();
|
||||||
/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。 */
|
/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。
|
||||||
|
* WeakSet 只记录这项声明,不能自动证明函数无副作用或不读取闭包状态。 */
|
||||||
export function builtinDataValidator<T extends ComponentDefinition['validate']>(validate:T):T{builtinValidators.add(validate);return validate;}
|
export function builtinDataValidator<T extends ComponentDefinition['validate']>(validate:T):T{builtinValidators.add(validate);return validate;}
|
||||||
/** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。
|
/** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。
|
||||||
* 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */
|
* 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */
|
||||||
@@ -29,6 +31,7 @@ export function componentDataValid(record:ElementRecord,definition:ComponentDefi
|
|||||||
// record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。
|
// record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。
|
||||||
const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate);
|
const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate);
|
||||||
if(reusable&&dataProofs.get(record)===validate)return true;
|
if(reusable&&dataProofs.get(record)===validate)return true;
|
||||||
|
// 校验器抛错会直接传播给上层;这里只缓存正常返回且通过的结果,不吞错或缓存失败。
|
||||||
const valid=Reflect.apply(validate,definition,[record.data]);
|
const valid=Reflect.apply(validate,definition,[record.data]);
|
||||||
if(valid&&reusable)dataProofs.set(record,validate);
|
if(valid&&reusable)dataProofs.set(record,validate);
|
||||||
return valid;
|
return valid;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。
|
* 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。
|
||||||
* 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。
|
* 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。
|
||||||
|
* capability 表示安装了哪类行为,schema 与各 handler 的实际检查仍须分别阅读。
|
||||||
*/
|
*/
|
||||||
import {builtinDataValidator} from './element-data.ts';
|
import {builtinDataValidator} from './element-data.ts';
|
||||||
import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts';
|
import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts';
|
||||||
@@ -15,12 +16,14 @@ const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geom
|
|||||||
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
|
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
|
||||||
const patch = input.patch as Partial<Geometry>;
|
const patch = input.patch as Partial<Geometry>;
|
||||||
if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]);
|
if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]);
|
||||||
|
// constrain 在字段形状检查后执行;此处不归一 rotation 或单独证明最终宽高为正,交给文档几何校验。
|
||||||
const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch };
|
const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch };
|
||||||
return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }];
|
return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }];
|
||||||
};
|
};
|
||||||
/** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */
|
/** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */
|
||||||
export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) });
|
export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) });
|
||||||
/** 捕获 min/max/aspect 配置,构造只修改 width/height 的缩放行为。 */
|
/** 浅捕获 min/max/aspect 选项,检查显式 min/max,再构造只修改 width/height 的缩放行为。
|
||||||
|
* 缺省 min=1、max=100000;aspect 默认不启用,内置图片也复用这份默认缩放 feature。 */
|
||||||
export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => {
|
export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => {
|
||||||
const options = Object.freeze({ ...inputOptions });
|
const options = Object.freeze({ ...inputOptions });
|
||||||
if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint');
|
if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint');
|
||||||
@@ -32,6 +35,7 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b
|
|||||||
return geometryHandler(['width', 'height'], geometry => {
|
return geometryHandler(['width', 'height'], geometry => {
|
||||||
// owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。
|
// owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。
|
||||||
const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units;
|
const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units;
|
||||||
|
// 先逐维 clamp,再按原 width/height 比例重算另一维;同时给宽高且 aspect=true 时以宽度为主。
|
||||||
let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height));
|
let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height));
|
||||||
// 宽度优先;只给高度时反算宽度。保持原比例后越界则拒绝,不分别截断而破坏比例。
|
// 宽度优先;只给高度时反算宽度。保持原比例后越界则拒绝,不分别截断而破坏比例。
|
||||||
if (options.aspect) {
|
if (options.aspect) {
|
||||||
@@ -44,13 +48,16 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
/** 旋转只产生 rotation 修改;[0,360) 等最终范围约束由几何校验处理。 */
|
/** 旋转 handler 只接受 rotation patch,但输出的是整份 geometry 字段变化。
|
||||||
|
* 不把 360/-1 自动归一到 [0,360),最终几何校验负责拒绝越界结果。 */
|
||||||
export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) });
|
export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) });
|
||||||
/** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */
|
/** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */
|
||||||
export const editable = () => defineFeature('content.edit', {
|
export const editable = () => defineFeature('content.edit', {
|
||||||
'content.edit': ({ element, input }) => {
|
'content.edit': ({ element, input }) => {
|
||||||
const patch = input.patch as Data;
|
const patch = input.patch as Data;
|
||||||
if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]);
|
if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]);
|
||||||
|
// 签名要求 Data 对象;上面的运行时 guard 检查 JSON 值域并排除数组,不是任意未知输入的完整对象 schema。
|
||||||
|
// patch 中出现的键都表示写入,null 是显式值;此 handler 没有以 undefined 表示删除的约定。
|
||||||
// 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。
|
// 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。
|
||||||
return Object.entries(patch).flatMap(([key, after]): FieldChange[] => {
|
return Object.entries(patch).flatMap(([key, after]): FieldChange[] => {
|
||||||
const before = element.data[key] ?? null;
|
const before = element.data[key] ?? null;
|
||||||
@@ -58,7 +65,8 @@ export const editable = () => defineFeature('content.edit', {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
/** 内置节点的基础 data 合同:title/text 为字符串,允许其它合法 JSON 扩展字段。 */
|
/** 内置节点共用的基础 data 合同:title/text 为字符串,检查已识别 unitScale,允许其它合法 JSON 扩展。
|
||||||
|
* 图片 URL/资源身份、图标名或任务字段并不由此函数逐项验证;它也不代表所有表现字段都有专门 schema。 */
|
||||||
const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string');
|
const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string');
|
||||||
/** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。
|
/** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。
|
||||||
* 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */
|
* 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */
|
||||||
@@ -67,18 +75,23 @@ export function builtinComponents(): readonly ComponentDefinition[] {
|
|||||||
id, dataVersion: 1, structure: 'node', validate: textData,
|
id, dataVersion: 1, structure: 'node', validate: textData,
|
||||||
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
|
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
|
||||||
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
|
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
|
||||||
// frame 的 ports 引用必须随复制映射;任务/run/progress 不复制成新的执行任务。
|
// frame ports[].nodeId 是显式 element 引用,复制范围外引用按 reject;局部 port id/handle 不是这里的映射目标。
|
||||||
|
// 只 omit 下列四个顶层任务状态字段,不递归猜测任意插件任务结构;资源字段按声明另供 runtime 扫描。
|
||||||
geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] },
|
geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] },
|
||||||
|
// 后续 connection 使用单独定义:strokeWidth 取 appearance.width 的真值或 1.5,并不在这里验证其类型/正数范围。
|
||||||
|
// segments 要求 displayPath 已提供且可解释,再调用共享 Bezier 生成器;输出有效性由几何查询边界继续检查。
|
||||||
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]);
|
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。 */
|
/** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。
|
||||||
|
* 这里只检查所需路径字段,不拒绝所有扩展键;corner/smooth/symmetric 是模式标签,不在此验证控制柄相切或镜像关系。 */
|
||||||
export function connectionPathValid(path: Json): boolean {
|
export function connectionPathValid(path: Json): boolean {
|
||||||
if (path === null) return true;
|
if (path === null) return true;
|
||||||
if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false;
|
if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false;
|
||||||
// 控制点只能是有限 x/y;缺少坐标、数组或非对象值均不接受。
|
// 控制点只能是有限 x/y;缺少坐标、数组或非对象值均不接受。
|
||||||
const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y);
|
const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y);
|
||||||
if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false;
|
if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false;
|
||||||
|
// 每个 knot 的 ID 非空且本路径内唯一;位置和 in/out 都必须是有限点,空 knots 数组也合法。
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const knot of path.knots) {
|
for (const knot of path.knots) {
|
||||||
if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false;
|
if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false;
|
||||||
@@ -86,7 +99,8 @@ export function connectionPathValid(path: Json): boolean {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。 */
|
/** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。
|
||||||
|
* feature ID 为 connection.path,但注册 capability 键是 connection.edit;editor 按后者查 handler。 */
|
||||||
export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => {
|
export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => {
|
||||||
const path = (input.patch as { path?: Json }).path;
|
const path = (input.patch as { path?: Json }).path;
|
||||||
if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]);
|
if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 与渲染框架无关的基本几何协议。所有计算输入必须在同一坐标空间;
|
* 与渲染框架无关的基本几何协议。所有计算输入必须在同一坐标空间;
|
||||||
* bounds 负责粗筛,hit 负责矩形精判,曲线 segments 由模型命中和 SVG 绘制共享。
|
* bounds 负责粗筛,hit 负责矩形精判,曲线 segments 由模型命中和 SVG 绘制共享。
|
||||||
|
* 函数不主动校验输入几何/容差/控制点,也不换算 viewport 或 presentation.unitScale,调用方先统一空间。
|
||||||
*/
|
*/
|
||||||
import type {Geometry,Point} from './types.ts';
|
import type {Geometry,Point} from './types.ts';
|
||||||
/** 轴对齐包围盒,用于空间索引粗筛;旋转节点的盒会大于其实际轮廓。 */
|
/** 轴对齐包围盒,用于空间索引粗筛;旋转节点的盒会大于其实际轮廓。 */
|
||||||
@@ -14,12 +15,15 @@ export type BezierChain={readonly knots:readonly{readonly position:Point;readonl
|
|||||||
/** 绕节点中心旋转后的 AABB:宽=|cos|·w+|sin|·h,高=|sin|·w+|cos|·h。 */
|
/** 绕节点中心旋转后的 AABB:宽=|cos|·w+|sin|·h,高=|sin|·w+|cos|·h。 */
|
||||||
export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};}
|
export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};}
|
||||||
/** 将点反向旋转到矩形自身坐标,计算到矩形的距离,再与容差比较。
|
/** 将点反向旋转到矩形自身坐标,计算到矩形的距离,再与容差比较。
|
||||||
* 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。 */
|
* 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。
|
||||||
|
* 点在矩形内部时距离为 0;tolerance 由调用方保证非负,正常边界含在命中内。 */
|
||||||
export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);}
|
export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);}
|
||||||
/** 默认矩形节点协议;空 dataDependencies 表示形状仅依赖 geometry,不因 title 等数据变化重建。 */
|
/** 默认矩形节点协议;空 dataDependencies 表示形状仅依赖 geometry,不因 title 等数据变化重建。 */
|
||||||
export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)});
|
export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)});
|
||||||
/** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */
|
/** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */
|
||||||
/** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。
|
/** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。
|
||||||
* 无显式首尾控制柄时,按端点距离的 0.4 倍选择长度并限制在 40–240。
|
* 无显式首尾控制柄时,reach 按两端直线距离的 0.4 倍限制在 40–240,再乘相应法向量。
|
||||||
* add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段。 */
|
* 本函数不归一 normal;只有单位法向量才使 reach 等于控制柄的实际长度。
|
||||||
|
* add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段,共 knots.length+1 段。
|
||||||
|
* 无 knot 时直接生成端点间一段;这里不根据 smooth/symmetric 标签自动修正控制柄。 */
|
||||||
export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index<knots.length?knots[index].in:path?.endControl??{x:to.normal.x*reach,y:to.normal.y*reach}),anchors[index+1]]);}
|
export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index<knots.length?knots[index].in:path?.endControl??{x:to.normal.x*reach,y:to.normal.y*reach}),anchors[index+1]]);}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ type PlacementStep={canvasId:Id;parentCanvasId:Id;entryId:Id;incarnation:number;
|
|||||||
/** A short-lived reader of the visible owner chain relative to one view, not the project root.
|
/** A short-lived reader of the visible owner chain relative to one view, not the project root.
|
||||||
* Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */
|
* Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */
|
||||||
/** 给定不可变文档/view/registry,返回短生命周期 owner→入口链读取器。
|
/** 给定不可变文档/view/registry,返回短生命周期 owner→入口链读取器。
|
||||||
* Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。 */
|
* Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。
|
||||||
|
* 返回数组/步骤没有运行时 freeze,geometry 也沿用记录引用;readonly 是调用约定,不应修改缓存结果。 */
|
||||||
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
|
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
|
||||||
|
// 链按 owner→view 方向排列,不包含 view 自身在更上级的入口;读同一 owner 会返回同一个数组。
|
||||||
const values=new Map<Id,readonly PlacementStep[]>();
|
const values=new Map<Id,readonly PlacementStep[]>();
|
||||||
/** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */
|
/** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */
|
||||||
return(ownerCanvasId:Id):readonly PlacementStep[]=>{
|
return(ownerCanvasId:Id):readonly PlacementStep[]=>{
|
||||||
@@ -24,6 +26,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
|
|||||||
while(id!==viewCanvasId){
|
while(id!==viewCanvasId){
|
||||||
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
|
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
|
||||||
const parent=document.canvases.get(id)?.parent;
|
const parent=document.canvases.get(id)?.parent;
|
||||||
|
// owner 缺失或已回溯到无父根,统一视为不在此 view;仅 view 本身缺失在上方报 canvas_missing。
|
||||||
if(!parent)throw fault('target_outside_view',[ownerCanvasId]);
|
if(!parent)throw fault('target_outside_view',[ownerCanvasId]);
|
||||||
const entry=document.elements.get(parent.entryElementId);
|
const entry=document.elements.get(parent.entryElementId);
|
||||||
// A future/unknown entry is a collapsed placeholder; never interpret its payload.
|
// A future/unknown entry is a collapsed placeholder; never interpret its payload.
|
||||||
@@ -31,6 +34,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
|
|||||||
// 未知/未来入口已在上方拒绝;已知入口仍可能折叠,折叠子内容不允许开始手势。
|
// 未知/未来入口已在上方拒绝;已知入口仍可能折叠,折叠子内容不允许开始手势。
|
||||||
const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
|
const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
|
||||||
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]);
|
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]);
|
||||||
|
// 签名只读这些坐标字段,不校验整个 presentation;contentScale 这里仅检查 number 且 >0。
|
||||||
const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1;
|
const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1;
|
||||||
chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)});
|
chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)});
|
||||||
id=parent.canvasId;
|
id=parent.canvasId;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 每个 editor 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用一份已提交文档。
|
* 一个 manager 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用 editor 已提交文档。
|
||||||
* 同时至多存在一个节点或连线手势;baseline/preview 均是短生命周期状态,不写 history/ledger。
|
* 单个 manager 内至多一个节点或连线手势;同一 editor 若另建 manager,并不共享这项互斥。
|
||||||
* pointerup 时才通过 editor 提交净修改;取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK。
|
* baseline/preview 均是短生命周期状态,不写 history/ledger。
|
||||||
|
* 输入绑定通常在 pointerup 调用 commit;本模块不接 DOM 事件,只有显式 commit 才发 editor 命令。
|
||||||
|
* 取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK;session listener 仍由本模块同步通知。
|
||||||
*/
|
*/
|
||||||
import type { Editor } from './editor.ts';
|
import type { Editor } from './editor.ts';
|
||||||
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts';
|
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts';
|
||||||
@@ -24,10 +26,11 @@ export function createSessions(editor: Editor) {
|
|||||||
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
|
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
|
||||||
// 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。
|
// 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。
|
||||||
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
|
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
|
||||||
// Compare adjacent published identities without duplicating the per-view mode table.
|
// 相邻 editor 发布的文档身份用于 mode/坐标签名比较;runtime-only 发布仍会重新检查 editor.can 的当前准入。
|
||||||
let publishedDocument=editor.getSnapshot().document;
|
let publishedDocument=editor.getSnapshot().document;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
/** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。 */
|
/** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。
|
||||||
|
* 会话本身没有 entered 重入闸门;监听器不应同步递归制造新会话操作。 */
|
||||||
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
|
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
|
||||||
/** 一次发布 views;若被中断的是手势拥有者,同时清 baseline 和预览,只通知一次。 */
|
/** 一次发布 views;若被中断的是手势拥有者,同时清 baseline 和预览,只通知一次。 */
|
||||||
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
|
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
|
||||||
@@ -37,7 +40,8 @@ export function createSessions(editor: Editor) {
|
|||||||
}
|
}
|
||||||
/** 复制视图表并替换一项,可在同一发布中带上切工具/切 canvas 等取消原因。 */
|
/** 复制视图表并替换一项,可在同一发布中带上切工具/切 canvas 等取消原因。 */
|
||||||
function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); }
|
function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); }
|
||||||
/** 幂等取消当前手势,保留 committed 文档及资源状态;重复 cancel 不重复通知。 */
|
/** 幂等取消本 manager 当前手势,保留 committed 文档及资源状态;重复 cancel 不重复通知。
|
||||||
|
* cancel 不接 viewId,属于 manager 级清理入口,不执行单 view 所有权校验。 */
|
||||||
function cancel(reason = 'canceled') {
|
function cancel(reason = 'canceled') {
|
||||||
if (!snapshot.gesture && !snapshot.connectionGesture) return;
|
if (!snapshot.gesture && !snapshot.connectionGesture) return;
|
||||||
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
|
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
|
||||||
@@ -49,6 +53,7 @@ export function createSessions(editor: Editor) {
|
|||||||
for (const [id, view] of views) {
|
for (const [id, view] of views) {
|
||||||
// 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。
|
// 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。
|
||||||
const canvasMissing = !document.canvases.has(view.canvasId);
|
const canvasMissing = !document.canvases.has(view.canvasId);
|
||||||
|
// selection 只保留仍存在的 ID,不按 incarnation/owner/可见性重新筛;mode 才比较相邻发布的完整身份字段。
|
||||||
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => {
|
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => {
|
||||||
const before=beforeDocument.elements.get(target),after=document.elements.get(target);
|
const before=beforeDocument.elements.get(target),after=document.elements.get(target);
|
||||||
return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion;
|
return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion;
|
||||||
@@ -64,7 +69,8 @@ export function createSessions(editor: Editor) {
|
|||||||
const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined;
|
const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined;
|
||||||
/** 同文档无需重算;旧/新链不再可读或值变化都按坐标上下文失效处理。 */
|
/** 同文档无需重算;旧/新链不再可读或值变化都按坐标上下文失效处理。 */
|
||||||
const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}};
|
const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}};
|
||||||
// 连线预览只依赖 path、身份、owner 和准入;普通内容或资源 ACK 不应自动取消它。
|
// 连线预览检查 path、身份、owner、坐标祖先链和当前准入;普通内容/资源 ACK 本身不直接取消,
|
||||||
|
// 但若该发布使 policy 不再允许 connection.path,后面的 editor.can 仍会取消。
|
||||||
if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') {
|
if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') {
|
||||||
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline;
|
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline;
|
||||||
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed';
|
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed';
|
||||||
@@ -100,7 +106,8 @@ export function createSessions(editor: Editor) {
|
|||||||
['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))],
|
['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))],
|
||||||
['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))],
|
['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))],
|
||||||
];
|
];
|
||||||
// move/resize/rotate 依次读取上一步预览结果;handler 只能给同目标生成合法 geometry。
|
// update 不以空 patch 做整命令 policy 预检,而是逐项检查实际使用的 capability、handler 输出目标与几何。
|
||||||
|
// move/resize/rotate 依次读取上一步预览结果;真正 commit 仍会通过 editor 再查 policy。
|
||||||
for (const [capability, value] of parts) if (Object.keys(value).length) {
|
for (const [capability, value] of parts) if (Object.keys(value).length) {
|
||||||
if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]);
|
if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]);
|
||||||
const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) });
|
const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) });
|
||||||
@@ -112,6 +119,7 @@ export function createSessions(editor: Editor) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 更新 preview 不触发 editor 命令,所以 pointermove 不制造 history 或出站请求。
|
// 更新 preview 不触发 editor 命令,所以 pointermove 不制造 history 或出站请求。
|
||||||
|
// 即使 patch 结果相同,本路径仍替换会话快照并 notify,不像 tool/mode 有相同值早返回。
|
||||||
const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry);
|
const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry);
|
||||||
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify();
|
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify();
|
||||||
return { enabled: true };
|
return { enabled: true };
|
||||||
@@ -120,36 +128,42 @@ export function createSessions(editor: Editor) {
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */
|
/** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */
|
||||||
getSnapshot: () => snapshot,
|
getSnapshot: () => snapshot,
|
||||||
/** 创建独立 view,返回带 attached 标记的幂等卸载函数;只释放这个 view 与其手势。 */
|
/** 创建独立 view,返回内部带 attached 布尔标记的幂等卸载闭包;只释放这个 view 与其手势。
|
||||||
|
* 已 dispose 或重复 view ID 都报 duplicate_view;卸载不关闭 editor,也不停止 runtime 持久任务。 */
|
||||||
attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) {
|
attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) {
|
||||||
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
|
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
|
||||||
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
||||||
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
|
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
|
||||||
let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); };
|
let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); };
|
||||||
},
|
},
|
||||||
/** 切换查看的 canvas,清 selection/modes,并取消属于该 view 的旧坐标手势。 */
|
/** 设置查看的 canvas,清 selection/modes,并取消属于该 view 的旧坐标手势。
|
||||||
|
* 即使传相同 canvasId 也走清理/通知;它不是相同值 no-op 的入口。 */
|
||||||
canvas(id: Id, canvasId: Id) {
|
canvas(id: Id, canvasId: Id) {
|
||||||
const current = view(id);
|
const current = view(id);
|
||||||
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
||||||
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed');
|
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed');
|
||||||
},
|
},
|
||||||
/** 捕获有效元素 ID 为只读选择集合;这是会话选择,不是文档命令。 */
|
/** 捕获存在的元素 ID 为去重选择集合;不要求属于此 view、可见或已安装组件。
|
||||||
|
* selection 不携带 incarnation,也不是授权证据;begin/提交另行核对目标与准入。 */
|
||||||
select(id: Id, ids: readonly Id[]) {
|
select(id: Id, ids: readonly Id[]) {
|
||||||
const current = view(id), document = editor.getSnapshot().document;
|
const current = view(id), document = editor.getSnapshot().document;
|
||||||
for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]);
|
for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]);
|
||||||
setView(freeze({ ...current, selection: readSet(ids) }));
|
setView(freeze({ ...current, selection: readSet(ids) }));
|
||||||
},
|
},
|
||||||
/** 保存有限 pan 和正 zoom;改变观察视口不修改元素几何或 history。 */
|
/** 保存有限 pan 和正 zoom;不修改元素几何/history,也不自动取消当前手势。
|
||||||
|
* 屏幕输入如何随 pan/zoom 换算由输入绑定负责,此处不保存 DOM 指针坐标。 */
|
||||||
viewport(id: Id, viewport: ViewState['viewport']) {
|
viewport(id: Id, viewport: ViewState['viewport']) {
|
||||||
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
|
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
|
||||||
setView(freeze({ ...view(id), viewport: { ...viewport } }));
|
setView(freeze({ ...view(id), viewport: { ...viewport } }));
|
||||||
},
|
},
|
||||||
/** select/pan 切换会取消本 view 的手势;重复设置相同工具不发通知。 */
|
/** select/pan 切换会取消本 view 的手势;重复设置相同工具不发通知。 */
|
||||||
tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); },
|
tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); },
|
||||||
/** 保存某元素的临时编辑模式;空值移除,不创建持久的业务字段。 */
|
/** 保存某元素的临时模式字符串;null/空串移除,非空值要求元素存在。
|
||||||
|
* 不在此枚举模式名或检查 capability;mode 不是可编辑权限,也不是持久业务字段。 */
|
||||||
mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
|
mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
|
||||||
/** 节点手势准入:select 工具、无其它手势、目标去重/版本支持、可见拥有链及 policy 全部通过。
|
/** 节点手势准入:select 工具、无其它手势、目标去重/版本支持、可见拥有链及 policy 全部通过。
|
||||||
* 先把整组选区检查完再替换 baseline,拒绝不会留下半组手势。 */
|
* 先把整组目标检查完再替换 baseline,拒绝不会留下半组手势;targets 不必等于 view.selection。
|
||||||
|
* 空 geometry patch 的 editor.can 不调用 move/resize/rotate handler,实际能力仍在 update/commit 检查。 */
|
||||||
begin(viewId: Id, targets: readonly Target[]): Availability {
|
begin(viewId: Id, targets: readonly Target[]): Availability {
|
||||||
try {
|
try {
|
||||||
if(view(viewId).tool!=='select')throw fault('tool_not_select');
|
if(view(viewId).tool!=='select')throw fault('tool_not_select');
|
||||||
@@ -188,7 +202,8 @@ export function createSessions(editor: Editor) {
|
|||||||
return { enabled: true };
|
return { enabled: true };
|
||||||
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||||
},
|
},
|
||||||
/** 连线结束只 dispatch 一次;先标 committing,结束后无论成功/拒绝都清预览。 */
|
/** 连线结束只 dispatch 一次;先标 committing,使 editor 发布监听暂不按 active 取消自己的提交。
|
||||||
|
* 正常提交和 catch 都调用 manager 级 cancel;错误 view 的提交调用也可能取消当前另一 view 的手势。 */
|
||||||
commitConnection(viewId: Id): Result {
|
commitConnection(viewId: Id): Result {
|
||||||
try {
|
try {
|
||||||
view(viewId);
|
view(viewId);
|
||||||
@@ -200,7 +215,9 @@ export function createSessions(editor: Editor) {
|
|||||||
} catch (error) { cancel('commit_failed'); return reject(error); }
|
} catch (error) { cancel('commit_failed'); return reject(error); }
|
||||||
},
|
},
|
||||||
update,
|
update,
|
||||||
/** 节点手势提交一个同步事务,把各目标相对 baseline 的净字段变化合成一个 Transform 历史项。 */
|
/** 节点手势提交一个同步事务,仅遍历 preview 中的目标,合并相对 baseline 的净字段变化。
|
||||||
|
* 有实际文档变化时通常形成一个 Transform 历史项;空预览/无净变化遵守 editor 的 no-op 语义。
|
||||||
|
* 与连线提交相同,异常路径会取消本 manager 当前手势,而不只清理传入的 view。 */
|
||||||
commit(viewId: Id): Result {
|
commit(viewId: Id): Result {
|
||||||
try {
|
try {
|
||||||
view(viewId);
|
view(viewId);
|
||||||
@@ -221,7 +238,8 @@ export function createSessions(editor: Editor) {
|
|||||||
} catch (error) { cancel('commit_failed'); return reject(error); }
|
} catch (error) { cancel('commit_failed'); return reject(error); }
|
||||||
},
|
},
|
||||||
cancel,
|
cancel,
|
||||||
/** 渲染时优先读取预览,否则读取最新 committed 几何;取消后自然显露最新模型。 */
|
/** 按元素 ID 优先读取本 manager 预览,否则读取最新 committed 几何;取消后自然显露最新模型。
|
||||||
|
* 无 viewId 参数,不过滤调用者视图;投影/renderer 负责决定这个元素是否应在该 view 显示。 */
|
||||||
effectiveGeometry(id: Id): Geometry | undefined {
|
effectiveGeometry(id: Id): Geometry | undefined {
|
||||||
const element = editor.getSnapshot().document.elements.get(id);
|
const element = editor.getSnapshot().document.elements.get(id);
|
||||||
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined);
|
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined);
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ export type HistoryTransition={readonly version:1;readonly rootRevision:number;r
|
|||||||
// only its flattened changes, never registry-dependent references or callbacks.
|
// only its flattened changes, never registry-dependent references or callbacks.
|
||||||
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
|
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
|
||||||
const immutableData=new WeakSet<object>();
|
const immutableData=new WeakSet<object>();
|
||||||
/** 检查可安全跨持久切点复用的深不可变普通数据,最多 64 层。
|
/** 检查缓存候选的冻结数据描述符与普通原型,最多递归 64 层;不是单独的 JSON/schema 校验器。
|
||||||
* 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */
|
* 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */
|
||||||
function immutablePlainData(value:unknown,depth=0):boolean{
|
function immutablePlainData(value:unknown,depth=0):boolean{
|
||||||
if(depth>64)return false;
|
if(depth>64)return false;
|
||||||
if(value===null||typeof value!=='object')return typeof value!=='function';
|
if(value===null||typeof value!=='object')return typeof value!=='function';
|
||||||
|
// 只记住已遍历成功的对象身份;祖先链若太深仍会先被 depth 条件拒绝。
|
||||||
if(immutableData.has(value))return true;
|
if(immutableData.has(value))return true;
|
||||||
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false;
|
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false;
|
||||||
if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false;
|
if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false;
|
||||||
@@ -44,7 +45,8 @@ function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
|
|||||||
}
|
}
|
||||||
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
|
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
|
||||||
/** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。
|
/** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。
|
||||||
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。 */
|
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。
|
||||||
|
* 来源应是不可变 history:两栈和区间内的 Change 沿用引用,最终 freeze 不等于任意外部输入的深复制。 */
|
||||||
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
|
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
|
||||||
const entries:EntryPatch[]=[];
|
const entries:EntryPatch[]=[];
|
||||||
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
|
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
|
||||||
@@ -52,9 +54,10 @@ export function describeHistoryTransition(before:HistoryState,after:HistoryState
|
|||||||
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
|
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
|
||||||
}
|
}
|
||||||
/** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。
|
/** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。
|
||||||
* checkChanges 可由调用方注入更严格的 Change 校验,本函数本身不依赖 registry。 */
|
* checkChanges 可由调用方注入更严格的 Change 校验;未传时只检查 DTO/区间形状,不解释每条 Change 的领域语义。
|
||||||
|
* rootRevision 只在此检查非负整数,不在此证明 transition 属于哪次实际发布。 */
|
||||||
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
|
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
|
||||||
// 四个局部校验器统一抛领域错误,分别约束普通记录、非负整数、单 ID 和 ID 数组。
|
// 四个局部校验器统一抛领域错误,分别约束非数组记录形状、非负整数、非空 ID 和 ID 数组。
|
||||||
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
|
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
|
||||||
const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
|
const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
|
||||||
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');};
|
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');};
|
||||||
@@ -72,8 +75,9 @@ export function validateHistoryTransition(value:unknown,checkChanges?:(value:unk
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
|
/** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
|
||||||
/** 先检查栈和每个区间仍匹配预期,再在私有副本上正向/反向回放元数据。
|
/** 先检查两栈,再逐 entry 检查元数据/数组区间并在私有副本上正向或反向回放。
|
||||||
* 任一冲突抛出而不修改输入 history;不能拿旧 journal 覆盖新的历史顺序。 */
|
* 不改写输入两栈/entries 表;不能拿旧 journal 覆盖新的历史顺序。
|
||||||
|
* history/transition 应已捕获:补丁内 Change 会沿用并冻结,不应把此接口当任意外部值的深隔离入口。 */
|
||||||
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{
|
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{
|
||||||
validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after;
|
validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after;
|
||||||
if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict');
|
if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict');
|
||||||
@@ -91,6 +95,7 @@ export function applyHistoryTransition(history:HistoryState,transition:HistoryTr
|
|||||||
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
|
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
|
||||||
const cached=transitionChanges.get(transition);if(cached)return cached;
|
const cached=transitionChanges.get(transition);if(cached)return cached;
|
||||||
validateHistoryTransition(transition);
|
validateHistoryTransition(transition);
|
||||||
|
// 仅收集被记录的变化区间两侧,未重复保存的共同前后缀不在本结果中;它不是完整 history 的展开。
|
||||||
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
|
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
|
||||||
// 属性来自原型或 getter 时不可把一次结果当永久事实,避免错误释放资源 pin。
|
// 属性来自原型或 getter 时不可把一次结果当永久事实,避免错误释放资源 pin。
|
||||||
const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));
|
const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ import { freeze } from './values.ts';
|
|||||||
|
|
||||||
/** Pure history execution, also used to validate recovery proposals before publication. */
|
/** Pure history execution, also used to validate recovery proposals before publication. */
|
||||||
/** 顺序回放一组历史修改,完成整份文档校验后返回候选文档和实际 changes。
|
/** 顺序回放一组历史修改,完成整份文档校验后返回候选文档和实际 changes。
|
||||||
* remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;失败时原文档不变。 */
|
* remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;函数不发布任何候选。
|
||||||
|
* initial/planned 应来自已捕获的不可变状态,底层 applyChanges/freeze 不是任意外部对象的深隔离入口。 */
|
||||||
export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){
|
export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){
|
||||||
// ID →(旧 incarnation → 新 incarnation);同一批恢复可能形成多级映射链。
|
// ID →(旧 incarnation → 新 incarnation);同一批恢复可能形成多级映射链。
|
||||||
const generations=new Map<Id,Map<number,number>>();
|
const generations=new Map<Id,Map<number,number>>();
|
||||||
// 追踪到最终代次,避免后面的字段修改仍指向本批较早的已替换代次。
|
// 追踪到本批最终代次;每次恢复分配更大代次,因此按这些内部生成的映射链前进。
|
||||||
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
|
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
|
||||||
// 只改生命周期身份:字段目标与整元素 before/after 都要一致,canvas 变更原样通过。
|
// 只改生命周期身份:字段目标与整元素 before/after 都要一致,canvas 变更原样通过。
|
||||||
const remap=(change:Change):Change=>{
|
const remap=(change:Change):Change=>{
|
||||||
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
|
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
|
||||||
|
// 整元素 before/after 只替换顶层 incarnation,记录内其它字段与引用不在此重新解释。
|
||||||
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
|
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
|
||||||
return change;
|
return change;
|
||||||
};
|
};
|
||||||
@@ -31,5 +33,6 @@ export function planHistoryChanges(initial:Document,planned:readonly Change[],re
|
|||||||
}
|
}
|
||||||
// 暂态允许先恢复端点再恢复边;只有整批完成后才检查最终树、引用和 owner 一致性。
|
// 暂态允许先恢复端点再恢复边;只有整批完成后才检查最终树、引用和 owner 一致性。
|
||||||
validateDocument(document,registry);
|
validateDocument(document,registry);
|
||||||
|
// restored 只表示是否建立过恢复代次映射;即使 false,文档也可能因普通字段/删除变化而改变。
|
||||||
return{document,changes:freeze(changes),remap,restored:generations.size>0};
|
return{document,changes:freeze(changes),remap,restored:generations.size>0};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts';
|
|||||||
export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts';
|
export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts';
|
||||||
export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts';
|
export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts';
|
||||||
|
|
||||||
// 查询按不可变文档快照绑定;更新 API 用于沿发布变化推进关系与空间索引。
|
// 查询按不可变文档快照绑定;此处公开建表/增量推进与查询类型。
|
||||||
|
// withQueryCoverage 是宿主加载链直接使用的模块 API,没有在这个根入口重导出。
|
||||||
export {createDocumentQueries,advanceDocumentQueries} from './queries.ts';
|
export {createDocumentQueries,advanceDocumentQueries} from './queries.ts';
|
||||||
export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts';
|
export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts';
|
||||||
|
|
||||||
|
// 公开共享几何函数;预算曲线细分 hitCubic 和 history 执行规划器等仍从内部模块使用,不在此整包重导出。
|
||||||
export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts';
|
export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts';
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {clone,freeze} from './values.ts';
|
|||||||
// Private provenance for a fresh, recursively frozen JSON clone. A caller can
|
// Private provenance for a fresh, recursively frozen JSON clone. A caller can
|
||||||
// request a detached copy, but cannot grant this fact to an existing object.
|
// request a detached copy, but cannot grant this fact to an existing object.
|
||||||
const detached=new WeakSet<object>();
|
const detached=new WeakSet<object>();
|
||||||
/** JSON 复制后递归冻结;对象结果记录到 WeakSet,原始值直接返回。
|
/** JSON 复制后递归冻结;仅对象结果记录到 WeakSet。
|
||||||
* clone 会按 JSON 规则处理数据,所以不可用它代替迁移输入的严格捕获校验。 */
|
* 输入先经过 JSON 序列化语义:toJSON/getter 可能执行,稀疏槽/数组中的非法 JSON 值可能转为 null。
|
||||||
|
* 循环或 BigInt 等仍会抛错;它不保留任意输入原貌,不能替代迁移输入的严格捕获校验。 */
|
||||||
export function detachJsonData<T>(input:T):T {
|
export function detachJsonData<T>(input:T):T {
|
||||||
const value=freeze(clone(input));
|
const value=freeze(clone(input));
|
||||||
if(value&&typeof value==='object')detached.add(value);
|
if(value&&typeof value==='object')detached.add(value);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* 关系表描述 owner、子 canvas、关联边与声明引用;空间表按 owner canvas 分网格存节点包围盒。
|
* 关系表描述 owner、子 canvas、关联边与声明引用;空间表按 owner canvas 分网格存节点包围盒。
|
||||||
* 更新采用 touched bucket 的 copy-on-write,未变索引保持身份,供局部订阅复用。
|
* 更新采用 touched bucket 的 copy-on-write,未变索引保持身份,供局部订阅复用。
|
||||||
* 查询完整性与命中成功分开:没查到不一定不存在,加载不完整/协议失败必须显式报告。
|
* 查询完整性与命中成功分开:没查到不一定不存在,加载不完整/协议失败必须显式报告。
|
||||||
|
* 工厂不执行 validateDocument;输入文档须已校验且不可变,局部加载的完整性由宿主绑定 coverage。
|
||||||
*/
|
*/
|
||||||
import {rectangleBounds,hitRectangle} from './geometry.ts';
|
import {rectangleBounds,hitRectangle} from './geometry.ts';
|
||||||
import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts';
|
import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts';
|
||||||
@@ -18,7 +19,8 @@ type Relation=Map<Id,ReadonlyValueSet<Id>>;
|
|||||||
type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>};
|
type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>};
|
||||||
/** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */
|
/** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */
|
||||||
type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean};
|
type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean};
|
||||||
/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。 */
|
/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。
|
||||||
|
* 这是格子枚举预算,不限制结果 ID 数或扫描耗时;超限查询可能转而扫描此 owner 全部节点。 */
|
||||||
export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number};
|
export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number};
|
||||||
/** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */
|
/** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */
|
||||||
type ResolvedBudget=Required<QueryBudget>;
|
type ResolvedBudget=Required<QueryBudget>;
|
||||||
@@ -49,7 +51,8 @@ function cells(box:Box,limit:number,cellSize:number):string[]|undefined{
|
|||||||
if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return;
|
if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return;
|
||||||
const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys;
|
const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys;
|
||||||
}
|
}
|
||||||
/** 只解析组件明确声明的复制引用;未知 schema/解析失败标记不完整,不能猜 payload 中的 ID。 */
|
/** 只解析组件 copy.references 的声明路径;未知 schema/解析失败标记不完整,不猜 payload 中的 ID。
|
||||||
|
* complete 仅说明这些声明可解析,不验证被引用对象存在、external 策略或隐藏在未声明字段中的引用。 */
|
||||||
function refs(record:ElementRecord,registry:Registry):References{
|
function refs(record:ElementRecord,registry:Registry):References{
|
||||||
const elements=new Set<Id>(),canvases=new Set<Id>(),schema=registry.components.get(record.componentId)?.copy;
|
const elements=new Set<Id>(),canvases=new Set<Id>(),schema=registry.components.get(record.componentId)?.copy;
|
||||||
if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false};
|
if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false};
|
||||||
@@ -72,7 +75,8 @@ export type ElementLookup={readonly status:'present';readonly element:ElementRec
|
|||||||
export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean};
|
export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean};
|
||||||
/** 与一个确切 document/registry/revision 绑定的公共查询。
|
/** 与一个确切 document/registry/revision 绑定的公共查询。
|
||||||
* members/child/incident 提供稳定排序;Result 版本另外携带加载完整性。
|
* members/child/incident 提供稳定排序;Result 版本另外携带加载完整性。
|
||||||
* candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。 */
|
* candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。
|
||||||
|
* 查询的是指定 owner 的直接节点,不自动投影整棵子树、折叠重定向或检查编辑权限。 */
|
||||||
export type DocumentQueries={
|
export type DocumentQueries={
|
||||||
readonly coverage:QueryCoverage;
|
readonly coverage:QueryCoverage;
|
||||||
lookupElement(id:Id):ElementLookup;
|
lookupElement(id:Id):ElementLookup;
|
||||||
@@ -101,7 +105,8 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
|
|||||||
// 关系子表按身份复用;referenceComplete 只有全部记录引用声明均可解释时才为真。
|
// 关系子表按身份复用;referenceComplete 只有全部记录引用声明均可解释时才为真。
|
||||||
const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0});
|
const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0});
|
||||||
const spatial=spatialViews(tables,previous);
|
const spatial=spatialViews(tables,previous);
|
||||||
/** 从网格收集候选并做 AABB 过滤;超大查询扫描全部 entries,普通查询同时加入 oversized。 */
|
/** 从网格收集候选并做 AABB 过滤;超大查询扫描全部 entries,普通查询同时加入 oversized。
|
||||||
|
* 空间表只含 node;曲线命中由其它适配路径完成,返回顺序按模型插入 rank,不是渲染命中优先级。 */
|
||||||
function candidates(canvas:Id,rect:Bounds){
|
function candidates(canvas:Id,rect:Bounds){
|
||||||
if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds');
|
if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds');
|
||||||
// Set 合并多个格子的重复 ID;无效 Box 保留为候选,后续精判负责报告不完整。
|
// Set 合并多个格子的重复 ID;无效 Box 保留为候选,后续精判负责报告不完整。
|
||||||
@@ -118,9 +123,11 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
|
|||||||
document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial,
|
document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial,
|
||||||
// 关系查询首先检查目标表项存在,再按元素/画布各自 rank 排序;不是遍历请求 queue。
|
// 关系查询首先检查目标表项存在,再按元素/画布各自 rank 排序;不是遍历请求 queue。
|
||||||
members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates,
|
members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates,
|
||||||
/** 点周围容差框粗筛,再调用自定义 hit(无协议则矩形回退);抛错项进入 unavailable。 */
|
/** 点周围容差框粗筛,再调用自定义 hit;无 node 协议或未知组件时回退到矩形,不自动列入 unavailable。
|
||||||
|
* 自定义 hit 抛错/返回非 boolean,或缓存 Box 非有限时才列 unavailable;命中不等于 registry 支持编辑。 */
|
||||||
hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});},
|
hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});},
|
||||||
/** 对候选按旋转矩形距离精判;无效几何使 complete=false,不当作可靠 miss。 */
|
/** 对候选按旋转矩形距离精判;缓存 Box 无效使 complete=false,不当作可靠 miss。
|
||||||
|
* 即使本次只需矩形,前面自定义 bounds 失败产生的 NaN Box 也会让该项跳过,不绕过共享粗筛失败。 */
|
||||||
hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});},
|
hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});},
|
||||||
});privateTables.set(view,tables);return view;
|
});privateTables.set(view,tables);return view;
|
||||||
}
|
}
|
||||||
@@ -128,10 +135,12 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
|
|||||||
const queryBases=new WeakMap<DocumentQueries,DocumentQueries>();
|
const queryBases=new WeakMap<DocumentQueries,DocumentQueries>();
|
||||||
// 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。
|
// 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。
|
||||||
const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>();
|
const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>();
|
||||||
/** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。 */
|
/** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。
|
||||||
|
* 同一来源对象后续被原地改动,再绑定也仍取第一次副本;新的证据必须传新的集合对象。 */
|
||||||
function ownedNamespace(ids:ReadonlyValueSet<Id>){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;}
|
function ownedNamespace(ids:ReadonlyValueSet<Id>){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;}
|
||||||
/** Bind completeness to the same publication as the model, without rebuilding indexes. */
|
/** Bind completeness to the same publication as the model, without rebuilding indexes. */
|
||||||
/** 不重建索引,仅替换同次发布中的加载证据与完整性方法;未知 canvas/矛盾完整声明拒绝。 */
|
/** 不重建索引,只绑定调用方提供的加载证据;检查重复/未知完整 canvas 以及 projectComplete 的显式矛盾。
|
||||||
|
* 不读取服务端 manifest,也不推导声明是否真实;基础 candidateNodes/关系读取仍只返回现有记录。 */
|
||||||
export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries {
|
export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries {
|
||||||
const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]);
|
const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]);
|
||||||
if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage');
|
if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage');
|
||||||
@@ -139,7 +148,8 @@ export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):D
|
|||||||
const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids);
|
const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids);
|
||||||
const view:DocumentQueries=Object.freeze({...base,coverage,
|
const view:DocumentQueries=Object.freeze({...base,coverage,
|
||||||
relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}),
|
relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}),
|
||||||
/** 已有记录优先 present;完整项目、命名空间排除、或已加载且有墓碑时才能判 absent。 */
|
/** 已有记录优先 present;projectComplete 可直接判缺失为 absent。
|
||||||
|
* 部分项目须有 elementIds:命名空间排除该 ID,或该 ID 已加载且有墓碑时才判 absent,其余 unloaded。 */
|
||||||
lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});},
|
lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});},
|
||||||
// 成员完整性按 owner,子树按 topology,关联边按整个项目,三者不能互相替代。
|
// 成员完整性按 owner,子树按 topology,关联边按整个项目,三者不能互相替代。
|
||||||
membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}),
|
membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}),
|
||||||
@@ -152,13 +162,16 @@ export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):D
|
|||||||
});queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view;
|
});queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view;
|
||||||
}
|
}
|
||||||
/** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */
|
/** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */
|
||||||
/** 从零构造纯派生索引,复用同一 advance 算法,避免维护两套建表规则。 */
|
/** 从零构造纯派生索引,复用 advance 建表;默认把输入当完整文档,不自动检测部分加载。
|
||||||
|
* 部分模型调用方必须随后 withQueryCoverage 绑定真实证据,否则默认 complete=true 不代表读取已完成。 */
|
||||||
export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);}
|
export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);}
|
||||||
/** 在当前发布前准备下一份索引;changes 精确列出受影响 ID,否则按新旧表并集检查。
|
/** 在当前发布前准备下一份索引;changes 精确列出受影响 ID,否则按新旧表并集检查。
|
||||||
* 必须与同一项目/registry/预算配合,revision 不可倒退,同 revision 不接受另一份 document。 */
|
* 必须与同一项目/registry/预算配合,revision 不可倒退,同 revision 不接受另一份 document。
|
||||||
|
* changes 是可信提示,不核对是否漏列真实差异;提供不完整/错误提示可能留下旧索引,无清单时才全表比较。 */
|
||||||
export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{
|
export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{
|
||||||
if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision');
|
if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision');
|
||||||
const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous;
|
const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous;
|
||||||
|
// 非空 options 是按默认值补齐后的完整预算声明,不是叠加旧预算的 patch;须与旧配置相等。
|
||||||
// 精确 changes 限定扫描范围;ACK 等没有差异列表的路径保守比较新旧记录。
|
// 精确 changes 限定扫描范围;ACK 等没有差异列表的路径保守比较新旧记录。
|
||||||
const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]);
|
const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]);
|
||||||
const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>();
|
const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>();
|
||||||
@@ -177,7 +190,7 @@ export function advanceDocumentQueries(previous:DocumentQueries|undefined,docume
|
|||||||
const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined;
|
const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined;
|
||||||
if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);}
|
if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);}
|
||||||
if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);}
|
if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);}
|
||||||
// 无法解释引用的活元素进入 opaque 集合,提示调用方不能把空引用结果当作完整证明。
|
// 无法解释引用的活元素进入 opaque 集合;opaqueReferences 描述引用可解释性,不表示整元素已失去几何/显示能力。
|
||||||
const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);}
|
const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);}
|
||||||
// 自定义几何未声明 dataDependencies 时保守重建;明确空数组则普通 data 编辑不重算 Box。
|
// 自定义几何未声明 dataDependencies 时保守重建;明确空数组则普通 data 编辑不重算 Box。
|
||||||
const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&®istry.resolve(now)==='known'?registry.components.get(now.componentId)?.geometry:undefined,shapeDataChanged=protocol?.kind==='node'&&!equal(before?.data,after?.data)&&(protocol.dataDependencies===undefined||protocol.dataDependencies.some(key=>!equal(before?.data[key],after?.data[key])));if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)||before?.componentId!==after?.componentId||before?.dataVersion!==after?.dataVersion||shapeDataChanged){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now,registry));}
|
const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&®istry.resolve(now)==='known'?registry.components.get(now.componentId)?.geometry:undefined,shapeDataChanged=protocol?.kind==='node'&&!equal(before?.data,after?.data)&&(protocol.dataDependencies===undefined||protocol.dataDependencies.some(key=>!equal(before?.data[key],after?.data[key])));if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)||before?.componentId!==after?.componentId||before?.dataVersion!==after?.dataVersion||shapeDataChanged){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now,registry));}
|
||||||
@@ -187,6 +200,7 @@ export function advanceDocumentQueries(previous:DocumentQueries|undefined,docume
|
|||||||
for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);}
|
for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);}
|
||||||
// 删除旧格子占用、再插新 Box;超限/无效 Box 放 oversized,不能因预算或错误漏出候选。
|
// 删除旧格子占用、再插新 Box;超限/无效 Box 放 oversized,不能因预算或错误漏出候选。
|
||||||
if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}}
|
if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}}
|
||||||
// 关系桶 finish 后装配;只在增删记录时重算全局插入顺序,最后返回与文档同 revision 的视图。
|
// 关系桶 finish 后装配;精确 changes 未报告增删时复用旧 rank,否则按文档迭代顺序重算/比较。
|
||||||
|
// expose 返回基础完整 coverage,不继承旧查询的加载声明;宿主应在同次发布重新绑定当前 coverage。
|
||||||
const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous);
|
const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* 组件定义的装配与显式数据迁移。Registry 保存可复用的定义/handler,不保存元素实例或 view 状态。
|
* 组件定义的装配与显式数据迁移。Registry 保存可复用的定义/handler,不保存元素实例或 view 状态。
|
||||||
* 装配时把 feature 合成为 componentId→capability→handler 查找表,冲突即拒绝,避免依赖安装顺序。
|
* 装配时把 feature 合成为 componentId→capability→handler 查找表,冲突即拒绝,避免依赖安装顺序。
|
||||||
* 迁移只生成候选记录,不写 editor;调用方负责把结果作为一次合法事务发布。
|
* 迁移只生成候选记录,不写 editor;调用方负责把结果作为一次合法事务发布。
|
||||||
|
* 冻结的是定义配置容器,handler 函数及其闭包仍沿用;容器只读不证明外部上下文不会变化。
|
||||||
*/
|
*/
|
||||||
import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts';
|
import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts';
|
||||||
import { clone, fault, freeze, readMap, readSet } from './values.ts';
|
import { clone, fault, freeze, readMap, readSet } from './values.ts';
|
||||||
@@ -11,7 +12,8 @@ export function defineFeature(id: string, handlers: Partial<Record<Capability, C
|
|||||||
if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature');
|
if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature');
|
||||||
return freeze({ id, handlers: { ...handlers } });
|
return freeze({ id, handlers: { ...handlers } });
|
||||||
}
|
}
|
||||||
/** 校验组件定义与复制/几何协议,捕获其配置容器,防止装配后被外部改写。 */
|
/** 校验组件定义与复制/几何协议,并复制声明过的配置容器后冻结。
|
||||||
|
* validate、几何方法、feature handler 和 migration 函数本身保持原引用,不复制函数闭包。 */
|
||||||
export function defineComponent(definition: ComponentDefinition): ComponentDefinition {
|
export function defineComponent(definition: ComponentDefinition): ComponentDefinition {
|
||||||
if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component');
|
if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component');
|
||||||
// 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。
|
// 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。
|
||||||
@@ -47,7 +49,8 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg
|
|||||||
}
|
}
|
||||||
components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found));
|
components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found));
|
||||||
}
|
}
|
||||||
// 只识别已安装的精确数据版本;可保留未知记录,但不能假设它可被当前 handler 编辑。
|
// 这里只按 componentId+dataVersion 分派,不执行 data schema 或结构校验;known 不是整条记录合法的证明。
|
||||||
|
// 可保留未知记录,但不能假设它可被当前 handler 编辑。
|
||||||
const resolve: Registry['resolve'] = element => {
|
const resolve: Registry['resolve'] = element => {
|
||||||
const definition = components.get(element.componentId);
|
const definition = components.get(element.componentId);
|
||||||
if (!definition) return 'unknown-component';
|
if (!definition) return 'unknown-component';
|
||||||
@@ -59,10 +62,10 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg
|
|||||||
supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) });
|
supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) });
|
||||||
}
|
}
|
||||||
/** Capture each field once; JSON.stringify would silently erase invalid values. */
|
/** Capture each field once; JSON.stringify would silently erase invalid values. */
|
||||||
/** 迁移边界的严格 JSON 捕获:每个字段只读取一次,捕获后递归冻结。
|
/** 迁移边界的 JSON 捕获:遍历的自有可枚举字符串字段取值一次,捕获后递归冻结。
|
||||||
* 不先 JSON.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */
|
* 不先 JSON.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */
|
||||||
function captureMigrationData(input:unknown,id:string):Data {
|
function captureMigrationData(input:unknown,id:string):Data {
|
||||||
// 统一带原元素 ID 抛出 invalid_migration,便于调用方保留原记录并报告失败对象。
|
// 本函数发现形状非法时带原元素 ID 抛 invalid_migration;getter/转换器自身抛出的异常不在这里包装。
|
||||||
const invalid=():never=>{throw fault('invalid_migration',[id]);};
|
const invalid=():never=>{throw fault('invalid_migration',[id]);};
|
||||||
// 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。
|
// 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。
|
||||||
const capture=(value:unknown,depth=0):Json=>{
|
const capture=(value:unknown,depth=0):Json=>{
|
||||||
@@ -78,6 +81,7 @@ function captureMigrationData(input:unknown,id:string):Data {
|
|||||||
const result:Json[]=[];for(let index=0;index<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result);
|
const result:Json[]=[];for(let index=0;index<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result);
|
||||||
}
|
}
|
||||||
if(Object.getPrototypeOf(value)!==Object.prototype)return invalid();
|
if(Object.getPrototypeOf(value)!==Object.prototype)return invalid();
|
||||||
|
// Object.keys 不遍历不可枚举字段;getter 在这里读取,结果复制为普通数据属性,不保留原描述符。
|
||||||
const source=value as Record<string,unknown>;
|
const source=value as Record<string,unknown>;
|
||||||
return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)])));
|
return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)])));
|
||||||
};
|
};
|
||||||
@@ -88,9 +92,11 @@ function captureMigrationData(input:unknown,id:string):Data {
|
|||||||
|
|
||||||
/** 按 migrations[旧版本] 逐级转换到当前版本,每步重新捕获,最后通过目标 schema。
|
/** 按 migrations[旧版本] 逐级转换到当前版本,每步重新捕获,最后通过目标 schema。
|
||||||
* 未知组件/未来版本原样保留;缺 converter 或任一步失败抛错,不返回半迁移记录。
|
* 未知组件/未来版本原样保留;缺 converter 或任一步失败抛错,不返回半迁移记录。
|
||||||
* ID、incarnation、owner 与其它信封字段不变,转换器只负责 data。 */
|
* ID、incarnation、owner 与其它信封字段值不变,转换器只负责 data。
|
||||||
|
* 其余信封对象按引用沿用,返回时 freeze 可能冻结这些共享嵌套值;调用方应传入已捕获的记录。 */
|
||||||
export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord {
|
export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord {
|
||||||
const definition = registry.components.get(element.componentId);
|
const definition = registry.components.get(element.componentId);
|
||||||
|
// 未安装/未来版本保持原引用且不捕获;即使版本已是当前版本,下方仍捕获 data 并校验 schema。
|
||||||
if (!definition || element.dataVersion > definition.dataVersion) return element;
|
if (!definition || element.dataVersion > definition.dataVersion) return element;
|
||||||
let data = captureMigrationData(element.data,element.id), version = element.dataVersion;
|
let data = captureMigrationData(element.data,element.id), version = element.dataVersion;
|
||||||
// 一个 converter 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。
|
// 一个 converter 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 owner 修复。
|
* 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 owner 修复。
|
||||||
* 只返回条件 Change,不直接发布 editor 状态;调用方把它们合入一次事务再校验。
|
* 只返回条件 Change,不直接发布 editor 状态;调用方把它们合入一次事务再校验。
|
||||||
|
* 文档应是有效、已捕获的不可变快照;返回 before 保留记录引用,freeze 不提供任意外部输入隔离。
|
||||||
*/
|
*/
|
||||||
import type {DocumentQueries} from './queries.ts';
|
import type {DocumentQueries} from './queries.ts';
|
||||||
import type { Change, Document, Id, Target } from './types.ts';
|
import type { Change, Document, Id, Target } from './types.ts';
|
||||||
@@ -18,11 +19,13 @@ export function removalChanges(document:Document,target:Target,queries?:Document
|
|||||||
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);}
|
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);}
|
||||||
// 关联边可能属于更上级 canvas,即使不在子树成员表中也必须随端点删除。
|
// 关联边可能属于更上级 canvas,即使不在子树成员表中也必须随端点删除。
|
||||||
for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge);
|
for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge);
|
||||||
|
// 按原表顺序输出元素删除,再输出 canvas 删除;最终关系合法性在事务整批完成后检查。
|
||||||
return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]);
|
return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]);
|
||||||
}
|
}
|
||||||
// 扫描路径构造 parent→children 临时表;Set 去重,stack 避免 JS 递归调用栈增长。
|
// 扫描路径构造 parent→children 临时表;Set 去重,stack 避免 JS 递归调用栈增长。
|
||||||
const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>();
|
const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>();
|
||||||
for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);}
|
for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);}
|
||||||
|
// 只从“目标是入口”的子 canvas 开始,不因节点视觉上处在某个矩形内就扩大删除范围。
|
||||||
const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id);
|
const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id);
|
||||||
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);}
|
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);}
|
||||||
// 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。
|
// 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。
|
||||||
@@ -35,11 +38,13 @@ export function removalChanges(document:Document,target:Target,queries?:Document
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */
|
/** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */
|
||||||
/** 端点重挂后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。
|
/** 端点或端点 owner 改变后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。
|
||||||
* 曲线坐标属于旧 owner 空间,因此 owner 与 path 必须作为同一操作一起转换。 */
|
* 曲线坐标属于旧 owner 空间,因此需要转换时 owner 与 path 必须作为同一操作一起提交。
|
||||||
|
* 只修复 owner 发生变化的边;不会仅因祖先几何变了就重写同 owner 的局部路径。 */
|
||||||
export function repairConnectionOwners(before:Document,document:Document):readonly Change[] {
|
export function repairConnectionOwners(before:Document,document:Document):readonly Change[] {
|
||||||
const changes:Change[]=[];
|
const changes:Change[]=[];
|
||||||
for(const edge of document.elements.values())if(edge.structure==='connection'){
|
for(const edge of document.elements.values())if(edge.structure==='connection'){
|
||||||
|
// 缺端点暂时跳过,交给整图校验拒绝;此规划器不会自行补端点或删除悬空边。
|
||||||
const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue;
|
const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue;
|
||||||
const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue;
|
const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue;
|
||||||
// 旧 owner 可能已从新文档移除:优先用 before 的变换解释旧曲线,再转到新 owner。
|
// 旧 owner 可能已从新文档移除:优先用 before 的变换解释旧曲线,再转到新 owner。
|
||||||
@@ -47,5 +52,6 @@ export function repairConnectionOwners(before:Document,document:Document):readon
|
|||||||
changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
|
changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
|
||||||
if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path});
|
if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path});
|
||||||
}
|
}
|
||||||
|
// 这里只返回补偿变化;document 参数不在本函数内被替换或发布。
|
||||||
return freeze(changes);
|
return freeze(changes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export type Point = { readonly x: number; readonly y: number };
|
|||||||
export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number };
|
export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number };
|
||||||
/** 拥有树的一条记录;根的 parent 为 null,子 canvas 指向父 canvas 及父内的入口元素。 */
|
/** 拥有树的一条记录;根的 parent 为 null,子 canvas 指向父 canvas 及父内的入口元素。 */
|
||||||
export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } };
|
export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } };
|
||||||
/** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,旧回调不能命中新对象。 */
|
/** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,由 targetElement 等运行时检查拦截旧目标。
|
||||||
|
* 类型本身不验证代次数值,也不是权限凭证。 */
|
||||||
export type Target = { readonly id: Id; readonly incarnation: number };
|
export type Target = { readonly id: Id; readonly incarnation: number };
|
||||||
/** 连线指向的节点及可选端口;端点的 owner 不必等于连线 owner。 */
|
/** 连线指向的节点及可选端口;端点的 owner 不必等于连线 owner。 */
|
||||||
export type Endpoint = { readonly elementId: Id; readonly portId?: string };
|
export type Endpoint = { readonly elementId: Id; readonly portId?: string };
|
||||||
@@ -28,11 +29,13 @@ export type ElementRecord = ElementBase & (
|
|||||||
| { readonly structure: 'node'; readonly geometry: Geometry }
|
| { readonly structure: 'node'; readonly geometry: Geometry }
|
||||||
| { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json }
|
| { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json }
|
||||||
);
|
);
|
||||||
/** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保留已死代次下限。 */
|
/** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保存每个 ID 已死亡的最大代次。
|
||||||
|
* Map 在运行时使用,持久化要经 codec;readonly 类型不等于 JSON 可直接序列化或输入已校验。 */
|
||||||
export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> };
|
export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> };
|
||||||
/** 可修改字段白名单;data. 后缀表示一个顶层键,整份 geometry 是一个条件字段。 */
|
/** 可修改字段白名单;data. 后缀表示一个顶层键,整份 geometry 是一个条件字段。 */
|
||||||
export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path';
|
export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path';
|
||||||
/** 字段条件变化:before/after 与存在性一起比较,使缺失和显式 null 不混淆。 */
|
/** 字段条件变化;data 顶层键结合 beforePresent/afterPresent 区分缺失与显式 null,缺省存在性为 true。
|
||||||
|
* 非 data 字段按整体 before/after 比较,不用这两个标记表达信封字段缺失。 */
|
||||||
export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean };
|
export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean };
|
||||||
/** 统一变化格式;元素/canvas 的 null 表示不存在,可表达创建、删除、替换及反向修改。 */
|
/** 统一变化格式;元素/canvas 的 null 表示不存在,可表达创建、删除、替换及反向修改。 */
|
||||||
export type Change = FieldChange
|
export type Change = FieldChange
|
||||||
@@ -59,13 +62,15 @@ export type Rejected = { readonly status: 'rejected'; readonly code: string; rea
|
|||||||
export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id };
|
export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id };
|
||||||
/** 公共命令结果的判别联合,调用方必须按 status 处理失败。 */
|
/** 公共命令结果的判别联合,调用方必须按 status 处理失败。 */
|
||||||
export type Result = Applied | Rejected;
|
export type Result = Applied | Rejected;
|
||||||
/** 一个逻辑撤销单元,包含正向变化和关联操作 ID;不把每个 pointermove 当一项。 */
|
/** 一个逻辑撤销单元,保存正向 changes 和关联操作 ID;一次 entry 可按 groupId 合并多次提交。
|
||||||
|
* undo 时计算逆变化,恢复代次后可修订这些正向 changes;它不是永远不变的原请求 body。 */
|
||||||
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
|
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
|
||||||
/** undo/redo 两个 ID 栈(尾部为栈顶),entries 保存记录,栈项与 ledger 事实分开。 */
|
/** undo/redo 两个 ID 栈(尾部为栈顶),entries 保存记录,栈项与 ledger 事实分开。 */
|
||||||
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
|
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
|
||||||
/** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 entry,避免多造用户步骤。 */
|
/** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 entry,避免多造用户步骤。 */
|
||||||
export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } };
|
export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } };
|
||||||
/** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。 */
|
/** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。
|
||||||
|
* operationSchemaVersion 是意图解释版本,historyTransition/historyEffect 是本地历史证据,不是服务端请求版本。 */
|
||||||
type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
|
type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
|
||||||
/** 生成任务的稳定输入;目标代次、模型/提示词/参数随逻辑操作保存,凭证不入文档。 */
|
/** 生成任务的稳定输入;目标代次、模型/提示词/参数随逻辑操作保存,凭证不入文档。 */
|
||||||
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
|
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
|
||||||
@@ -84,24 +89,31 @@ export type CoreSnapshot = { readonly revision: number; readonly document: Docum
|
|||||||
export type Snapshot<Runtime = unknown> = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime };
|
export type Snapshot<Runtime = unknown> = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime };
|
||||||
/** 命令或能力预检查结果;enabled=false 给出原因,不以按钮是否灰掉代替真正提交校验。 */
|
/** 命令或能力预检查结果;enabled=false 给出原因,不以按钮是否灰掉代替真正提交校验。 */
|
||||||
export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] };
|
export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] };
|
||||||
/** 只暴露读能力的集合合同,forEach 也只接触只读视图。 */
|
/** 类型合同仅列读方法;真正隐藏 add/delete 和保护 forEach 第三个参数要由 readSet 等工厂实现。
|
||||||
|
* TypeScript 结构兼容并不阻止外部把原生 Set 当作这个接口传入。 */
|
||||||
export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => void): void };
|
export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => void): void };
|
||||||
/** 编辑行为的稳定标识,组件通过安装 feature 获得对应 handler。 */
|
/** 编辑行为的稳定标识,组件通过安装 feature 获得对应 handler。 */
|
||||||
export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit';
|
export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit';
|
||||||
/** 读取当前元素和输入,返回字段变化;不能越权写其它元素,也不在这里发布或访问网络。 */
|
/** 读取当前元素和输入,返回字段变化;约定只规划而不发布/访问网络。
|
||||||
|
* editor 再检查输出目标/字段;函数类型不自动证明实现纯净,也不能回滚 handler 自己的外部副作用。 */
|
||||||
export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[];
|
export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[];
|
||||||
/** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */
|
/** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */
|
||||||
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
|
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
|
||||||
/** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */
|
/** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */
|
||||||
/** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组,external 决定外部引用保留/拒绝。 */
|
/** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组。
|
||||||
|
* external 是深复制的外部引用策略;其它规划器可复用路径,但不必沿用复制的 preserve/reject 语义。 */
|
||||||
export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' };
|
export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' };
|
||||||
/** 深复制时只重写声明引用并删 omit 字段;不能扫描任意相似字符串猜测引用。 */
|
/** 深复制时只重写声明引用并删 omit 字段;不能扫描任意相似字符串猜测引用。 */
|
||||||
export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] };
|
export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] };
|
||||||
/** 节点几何协议的计算输入;geometry 可是预览值,不要求等于记录的 committed 几何。 */
|
/** 节点几何协议的计算输入;geometry 可是预览值,不要求等于记录的 committed 几何。 */
|
||||||
export type NodeGeometryContext={readonly element:Extract<ElementRecord,{structure:'node'}>;readonly geometry:Geometry};
|
export type NodeGeometryContext={readonly element:Extract<ElementRecord,{structure:'node'}>;readonly geometry:Geometry};
|
||||||
/** 版本化的纯几何扩展:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。 */
|
/** 版本化的纯几何扩展约定:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。
|
||||||
|
* node dataDependencies 缺省时数据变化保守重算,空数组表示不依赖 data;role 提示 bounds 的用途。
|
||||||
|
* connection 输入已含展示锚点/变换,displayPath 可为 undefined;返回值仍由查询边界验证,类型不代替校验。 */
|
||||||
export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]});
|
export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]});
|
||||||
/** 一种组件的定义:精确版本/schema、feature、迁移、资源字段、几何及复制声明,可服务多个实例。 */
|
/** 一种组件的定义,可服务多个实例:dataVersion/validate、features、逐级 migrations、顶层 resourceFields、geometry 和 copy。
|
||||||
|
* 未提供 copy 不表示可以猜引用复制;resourceFields 只声明资源扫描入口,不在定义里保存文件字节或用户凭证。 */
|
||||||
export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema };
|
export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema };
|
||||||
/** 只读定义与编译后能力表;resolve 区分未知组件和不支持版本,supports 只允许精确已知版本。 */
|
/** 只读定义与编译后能力表;resolve 只按组件 ID/精确数据版本分类,不执行 data schema。
|
||||||
|
* supports 还要求 capability 已安装,仍不代替 policy、命令规划或最终文档校验。 */
|
||||||
export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };
|
export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };
|
||||||
|
|||||||
@@ -30,13 +30,15 @@ export function presentationRecord(value:Json|undefined):Readonly<Record<string,
|
|||||||
unitScale(value);return value;
|
unitScale(value);return value;
|
||||||
}
|
}
|
||||||
/** Normalize only the framework-owned unit field; all other presentation properties survive. */
|
/** Normalize only the framework-owned unit field; all other presentation properties survive. */
|
||||||
/** 返回替换比例后的元数据:等效值复用原对象,比例为 1 时省略保留字段。
|
/** 返回替换比例后的元数据:等效值原样复用;需要重写且新比例为 1 时才删除保留字段。
|
||||||
* 仅更新 unitScale,不清掉宿主扩展字段;字段全部清空时返回 undefined。 */
|
* 仅更新 unitScale,不清掉宿主扩展字段;字段全部清空时返回 undefined。 */
|
||||||
export function withUnitScale(value:Json|undefined,scale:number):Json|undefined {
|
export function withUnitScale(value:Json|undefined,scale:number):Json|undefined {
|
||||||
if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units');
|
if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units');
|
||||||
// 坐标换算可能产生接近 1 的浮点误差,统一表示能减少无意义的模型修改。
|
// 坐标换算可能产生接近 1 的浮点误差,统一表示能减少无意义的模型修改。
|
||||||
const normalized=closeNumber(scale,1)?1:scale;
|
const normalized=closeNumber(scale,1)?1:scale;
|
||||||
|
// no-op 先返回,因此显式 unitScale:1 不一定被删;原始值/数组按默认比例 1 等效时也可原样通过。
|
||||||
if(closeScale(unitScale(value),normalized))return value;
|
if(closeScale(unitScale(value),normalized))return value;
|
||||||
|
// 只有确实改比例才要求元数据可合并,拒绝原始值/数组,避免把宿主扩展值覆盖成对象。
|
||||||
const fields=new Map(Object.entries(presentationRecord(value)));
|
const fields=new Map(Object.entries(presentationRecord(value)));
|
||||||
if(normalized===1)fields.delete('unitScale');else fields.set('unitScale',normalized);
|
if(normalized===1)fields.delete('unitScale');else fields.set('unitScale',normalized);
|
||||||
return fields.size?Object.fromEntries(fields):undefined;
|
return fields.size?Object.fromEntries(fields):undefined;
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
import type { Id, Json, ReadonlyValueSet } from './types.ts';
|
import type { Id, Json, ReadonlyValueSet } from './types.ts';
|
||||||
|
|
||||||
/** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。 */
|
/** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。
|
||||||
|
* targets 沿用传入数组引用;这个辅助函数本身不捕获或冻结错误明细。 */
|
||||||
export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } {
|
export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } {
|
||||||
return Object.assign(new Error(code), { code, targets });
|
return Object.assign(new Error(code), { code, targets });
|
||||||
}
|
}
|
||||||
/** 将捕获的异常归一化为只读 rejected 结果;无领域错误码时使用 invalid_command。 */
|
/** 将捕获的异常归一化为浅冻结的 rejected 外壳;无领域错误码时使用 invalid_command。
|
||||||
|
* 已有 targets 数组仍沿用原引用,不应把外壳冻结解释为整个错误结果深度不可变。 */
|
||||||
export function reject(error: unknown) {
|
export function reject(error: unknown) {
|
||||||
const value = error as { code?: string; targets?: readonly string[] };
|
const value = error as { code?: string; targets?: readonly string[] };
|
||||||
return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] });
|
return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] });
|
||||||
@@ -24,13 +26,15 @@ export function freeze<T>(value: T): T {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
/** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。
|
/** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。
|
||||||
* 此处不是校验器:JSON 会丢弃某些非法字段,因此外部输入必须在复制前验证。 */
|
* 此处不是校验器:JSON 会执行 toJSON、读取可枚举字段并丢弃/改写不支持的值。
|
||||||
|
* 不保留原型、描述符或共享子对象身份;需要严格输入语义时须在调用边界捕获。 */
|
||||||
export function clone<T>(value: T): T {
|
export function clone<T>(value: T): T {
|
||||||
if (value === undefined) return value;
|
if (value === undefined) return value;
|
||||||
return JSON.parse(JSON.stringify(value)) as T;
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
}
|
}
|
||||||
/** 按可枚举自有键递归比较 JSON 风格的值;数组与对象不能互相视为相等。
|
/** 先以 Object.is 判断,再按可枚举自有字符串键递归比较 JSON 风格的值;数组与对象不能互相视为相等。
|
||||||
* 不处理任意循环对象,也不把 Map/Set 等实例当成业务值来比较。 */
|
* 不检查 Symbol/不可枚举字段,数组长度本身也不在 Object.keys 中;稀疏数组不适合作为此比较器的输入。
|
||||||
|
* 不处理任意循环对象,也不提供 Map/Set 成员比较语义。 */
|
||||||
export function equal(a: unknown, b: unknown): boolean {
|
export function equal(a: unknown, b: unknown): boolean {
|
||||||
if (Object.is(a, b)) return true;
|
if (Object.is(a, b)) return true;
|
||||||
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
|
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
|
||||||
@@ -46,6 +50,7 @@ export function isOwnedMapView(value:object){return ownedMapViews.has(value);}
|
|||||||
/** 复制表成员并仅暴露读取能力,外部既拿不到原 Map,也拿不到 set/delete。
|
/** 复制表成员并仅暴露读取能力,外部既拿不到原 Map,也拿不到 set/delete。
|
||||||
* value 保留原引用,深度不可变性由生产这些 value 的模块负责。 */
|
* value 保留原引用,深度不可变性由生产这些 value 的模块负责。 */
|
||||||
export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
|
export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
|
||||||
|
// 从此以后 input 的增删与本视图无关;但 K/V 对象仍共享引用,键/值本身不是在此深复制的。
|
||||||
const map = new Map(input);
|
const map = new Map(input);
|
||||||
const view: ReadonlyMap<K, V> = {
|
const view: ReadonlyMap<K, V> = {
|
||||||
get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key),
|
get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key),
|
||||||
@@ -58,6 +63,7 @@ export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
|
|||||||
}
|
}
|
||||||
/** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */
|
/** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */
|
||||||
export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
|
export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
|
||||||
|
// 成员表独立于原 iterable,成员若为对象则仍保留原身份;隐藏可变方法并不冻结对象成员。
|
||||||
const set = new Set(input);
|
const set = new Set(input);
|
||||||
const view: ReadonlyValueSet<T> = {
|
const view: ReadonlyValueSet<T> = {
|
||||||
get size() { return set.size; }, has: key => set.has(key), entries: () => set.entries(), keys: () => set.keys(), values: () => set.values(),
|
get size() { return set.size; }, has: key => set.has(key), entries: () => set.entries(), keys: () => set.keys(), values: () => set.values(),
|
||||||
@@ -66,7 +72,9 @@ export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
|
|||||||
return Object.freeze(view);
|
return Object.freeze(view);
|
||||||
}
|
}
|
||||||
/** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。
|
/** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。
|
||||||
* 这里只检查值形状,不验证组件 schema,也不是所有原型/属性描述符的严格捕获器。 */
|
* 对象要求 Object.prototype 原型,但只遍历 Object.values;Symbol/不可枚举属性不参与。
|
||||||
|
* 数组用 every 跳过空洞,也不检查额外属性;它不是稠密数组或所有属性描述符的严格捕获器。
|
||||||
|
* 校验读取可能触发 getter;这里不证明组件 schema、输入稳定性或数据所有权。 */
|
||||||
export function validJson(value: unknown, depth = 0): value is Json {
|
export function validJson(value: unknown, depth = 0): value is Json {
|
||||||
if (depth > 64) return false;
|
if (depth > 64) return false;
|
||||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
|
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
|
||||||
|
|||||||
@@ -26,14 +26,17 @@ export type FrozenRequest = {
|
|||||||
};
|
};
|
||||||
/** 意图及其本地解析状态;blocked 不等于丢弃,elided/retired 不等于收到服务器 applied 回执。 */
|
/** 意图及其本地解析状态;blocked 不等于丢弃,elided/retired 不等于收到服务器 applied 回执。 */
|
||||||
export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly resolution?: 'applied' | 'not-applied' | 'superseded' | 'discarded' | 'satisfied' | 'elided'; readonly elisionId?: Id; readonly elision?: ElisionProof; readonly recoveryId?: Id; readonly replacementId?: Id; readonly basePresence: Readonly<Record<Id, Presence>> };
|
export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly resolution?: 'applied' | 'not-applied' | 'superseded' | 'discarded' | 'satisfied' | 'elided'; readonly elisionId?: Id; readonly elision?: ElisionProof; readonly recoveryId?: Id; readonly replacementId?: Id; readonly basePresence: Readonly<Record<Id, Presence>> };
|
||||||
/** generation 随账本事实改变递增,durableGeneration 仅确认已实际存储的前缀。 */
|
/** generation 随账本事实改变递增;durableGeneration 是宿主报告的已存前缀。
|
||||||
|
* markDurable 只检查水位范围,本模块不访问存储或证明 fsync/事务真的完成。 */
|
||||||
export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> };
|
export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> };
|
||||||
|
|
||||||
/** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */
|
/** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */
|
||||||
/** 仅压缩完全未冻结的变化段:create 后字段修改并入新对象,同字段连续变化合并。
|
/** 用于调用方已确认完全未冻结的变化段:合并 create 后字段修改及同字段链。
|
||||||
* preserveLifecycle 保留创建/删除,preserveChecks 保留条件 no-op;不能用最后整节点覆盖替代字段链。 */
|
* 本函数不读 ledger、basePresence 或 possiblyApplied,不能单独证明可抵消已发送/未知结果的生命周期。
|
||||||
|
* preserveLifecycle 只阻止 create→delete 抵消,preserveChecks 只保留合并后字段 no-op;不是停用所有压缩。 */
|
||||||
export function compressChanges(changes: readonly Change[], options: { preserveLifecycle?: boolean; preserveChecks?: boolean } = {}): readonly Change[] {
|
export function compressChanges(changes: readonly Change[], options: { preserveLifecycle?: boolean; preserveChecks?: boolean } = {}): readonly Change[] {
|
||||||
// positions 按元素/canvas ID 保存结果索引栈,只尝试合并同对象最近变化,保留原条件顺序。
|
// positions 按元素/canvas ID 保存索引栈,只尝试同对象最近一项;其它对象的变化可以夹在两项之间。
|
||||||
|
// 若同对象中间出现不同字段,则不跨过它继续向前找同字段,因此不是任意字段的全段最后值覆盖。
|
||||||
const result: (Change | undefined)[] = [], positions = new Map<string, number[]>();
|
const result: (Change | undefined)[] = [], positions = new Map<string, number[]>();
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const id = change.kind === 'field' ? change.target.id : change.id;
|
const id = change.kind === 'field' ? change.target.id : change.id;
|
||||||
@@ -57,7 +60,8 @@ export function compressChanges(changes: readonly Change[], options: { preserveL
|
|||||||
return freeze(result.filter((change): change is Change => change !== undefined));
|
return freeze(result.filter((change): change is Change => change !== undefined));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从已有快照复制可变表,所有有效修改后重新捕获只读快照;旧 Snapshot 的成员不会改变。 */
|
/** 从已有快照复制 Map 成员,修改后发布新的只读表视图;不在此验证完整恢复 DTO。
|
||||||
|
* 初始记录沿用引用,宿主须先解码捕获为不可变记录;构造器不是任意外部对象的深隔离入口。 */
|
||||||
export function createLedger(initial?: LedgerSnapshot) {
|
export function createLedger(initial?: LedgerSnapshot) {
|
||||||
let generation = initial?.generation ?? 0, durableGeneration = initial?.durableGeneration ?? 0;
|
let generation = initial?.generation ?? 0, durableGeneration = initial?.durableGeneration ?? 0;
|
||||||
const operations = new Map(initial?.operations), requests = new Map(initial?.requests);
|
const operations = new Map(initial?.operations), requests = new Map(initial?.requests);
|
||||||
@@ -84,7 +88,8 @@ export function createLedger(initial?: LedgerSnapshot) {
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 读取最新捕获的只读事实,不暴露内部可变表。 */
|
/** 读取最新捕获的只读事实,不暴露内部可变表。 */
|
||||||
getSnapshot: () => cached,
|
getSnapshot: () => cached,
|
||||||
/** 校验身份/序列/基线与依赖,再添加 pending;同 ID 同意图幂等,改意图则拒绝。 */
|
/** 检查单条身份、正安全整数序列、基线枚举和依赖,再添加 pending。
|
||||||
|
* 不在此证明全账本 sequence 唯一/递增;同 ID 同意图直接返回,也不会更新第一次的 basePresence。 */
|
||||||
add(intent: OperationIntent, basePresence: Readonly<Record<Id, Presence>>) {
|
add(intent: OperationIntent, basePresence: Readonly<Record<Id, Presence>>) {
|
||||||
if (!intent.id || !Number.isSafeInteger(intent.sequence) || intent.sequence < 1 || new Set(intent.dependencies).size !== intent.dependencies.length || Object.values(basePresence).some(value => !['absent', 'exists', 'unknown'].includes(value))) throw fault('invalid_operation');
|
if (!intent.id || !Number.isSafeInteger(intent.sequence) || intent.sequence < 1 || new Set(intent.dependencies).size !== intent.dependencies.length || Object.values(basePresence).some(value => !['absent', 'exists', 'unknown'].includes(value))) throw fault('invalid_operation');
|
||||||
const existing = operations.get(intent.id);
|
const existing = operations.get(intent.id);
|
||||||
@@ -99,8 +104,9 @@ export function createLedger(initial?: LedgerSnapshot) {
|
|||||||
},
|
},
|
||||||
/** 给可变意图设置/清除 blocker;有原因变 blocked,无原因回 pending,不改冻结请求。 */
|
/** 给可变意图设置/清除 blocker;有原因变 blocked,无原因回 pending,不改冻结请求。 */
|
||||||
block(id: Id, reasons: readonly string[]) { const operation = operations.get(id); if (!operation || !['pending', 'blocked'].includes(operation.phase)) return; operations.set(id, freeze({ ...operation, phase: reasons.length ? 'blocked' : 'pending', blockers: [...reasons] })); changed(); },
|
block(id: Id, reasons: readonly string[]) { const operation = operations.get(id); if (!operation || !['pending', 'blocked'].includes(operation.phase)) return; operations.set(id, freeze({ ...operation, phase: reasons.length ? 'blocked' : 'pending', blockers: [...reasons] })); changed(); },
|
||||||
/** 原子绑定多个 ready 意图到同一请求;参与者版本精确覆盖、类型一致、依赖已解决或在同批。
|
/** 完成输入/ready 检查后,同次更新请求和所有操作的绑定:参与版本覆盖、类型一致,依赖已解决或在同批。
|
||||||
* 只在这里序列化 payload 一次,后续 submit/query 都使用保存的 body 和请求 ID。 */
|
* payload 在这里 JSON 序列化一次,后续沿用 body/ID;wire 语义、意图到参与者的推导由编译/调度层核对。
|
||||||
|
* 这次内存更新本身不等于一次持久事务,也不自动取得 scheduler claims。 */
|
||||||
freeze(input: { id: Id; kind?: FrozenRequest['kind']; ownerCanvasId?: Id; runId?: Id; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) {
|
freeze(input: { id: Id; kind?: FrozenRequest['kind']; ownerCanvasId?: Id; runId?: Id; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) {
|
||||||
if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !input.operationIds.length || (!input.canvasIds.length && input.kind!=='asset.upload' && input.kind!=='run.cancel') || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 0)) throw fault('invalid_frozen_request');
|
if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !input.operationIds.length || (!input.canvasIds.length && input.kind!=='asset.upload' && input.kind!=='run.cancel') || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 0)) throw fault('invalid_frozen_request');
|
||||||
if (input.kind === 'run.start' && (!input.ownerCanvasId || !input.canvasIds.includes(input.ownerCanvasId))) throw fault('invalid_run_request');
|
if (input.kind === 'run.start' && (!input.ownerCanvasId || !input.canvasIds.includes(input.ownerCanvasId))) throw fault('invalid_run_request');
|
||||||
@@ -122,11 +128,13 @@ export function createLedger(initial?: LedgerSnapshot) {
|
|||||||
if (request.phase === 'resolved') throw fault('request_resolved', [id]);
|
if (request.phase === 'resolved') throw fault('request_resolved', [id]);
|
||||||
const next: FrozenRequest = freeze({ ...request, phase: 'dispatch-marked', attempt: request.attempt + 1, retryAt: undefined, retryAction: undefined }); requests.set(id, next); changed(); return next;
|
const next: FrozenRequest = freeze({ ...request, phase: 'dispatch-marked', attempt: request.attempt + 1, retryAt: undefined, retryAction: undefined }); requests.set(id, next); changed(); return next;
|
||||||
},
|
},
|
||||||
/** 记录执行结果;applied 且 reconciled 才解除逻辑操作,受理/未协调成功仍 waiting。
|
/** 接受上层已解释的 Outcome;本函数不验证协议身份、canonical 模型或 not-applied 的零写入证据。
|
||||||
|
* applied 且 reconciled 才解除逻辑操作,受理/未协调成功仍 waiting;reconciled 默认 true,调用方需按协调状态传入。
|
||||||
* not-applied 结束旧请求但阻塞原意图,不能自动换 key 当作用户已重新授权执行。 */
|
* not-applied 结束旧请求但阻塞原意图,不能自动换 key 当作用户已重新授权执行。 */
|
||||||
result(id: Id, outcome: Outcome, error?: string, reconciled = true) {
|
result(id: Id, outcome: Outcome, error?: string, reconciled = true) {
|
||||||
const request = requireRequest(id);
|
const request = requireRequest(id);
|
||||||
if ((request.phase === 'resolved' || request.outcome === 'applied') && request.outcome !== outcome) throw fault('receipt_conflict', [id]);
|
if ((request.phase === 'resolved' || request.outcome === 'applied') && request.outcome !== outcome) throw fault('receipt_conflict', [id]);
|
||||||
|
// 这里只拒绝与既有 applied/resolved outcome 不同的回执;相同 outcome 的阶段准入仍由调用链维持。
|
||||||
const phase = (outcome === 'applied' && reconciled) || outcome === 'not-applied' ? 'resolved' : 'waiting';
|
const phase = (outcome === 'applied' && reconciled) || outcome === 'not-applied' ? 'resolved' : 'waiting';
|
||||||
requests.set(id, freeze({ ...request, phase, outcome, error, retryAt: undefined, retryAction: undefined }));
|
requests.set(id, freeze({ ...request, phase, outcome, error, retryAt: undefined, retryAction: undefined }));
|
||||||
if (outcome === 'applied' && reconciled) for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'resolved', resolution: 'applied' }));
|
if (outcome === 'applied' && reconciled) for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'resolved', resolution: 'applied' }));
|
||||||
@@ -161,7 +169,8 @@ export function createLedger(initial?: LedgerSnapshot) {
|
|||||||
},
|
},
|
||||||
/** Local retirement is not an applied receipt. Validate the complete closure before mutating. */
|
/** Local retirement is not an applied receipt. Validate the complete closure before mutating. */
|
||||||
/** 用户恢复决策的本地退休:先验证整组与依赖闭包,再标记 superseded/discarded/satisfied。
|
/** 用户恢复决策的本地退休:先验证整组与依赖闭包,再标记 superseded/discarded/satisfied。
|
||||||
* 上传和执行结果未知的请求不能在这里被静默丢弃;replacement ID 必须是全新的。 */
|
* 上传和执行结果未知的请求不能在这里被静默丢弃;replacement ID 不能撞已有/本批操作。
|
||||||
|
* 只标记原操作退休,不创建 replacement,不改工作文档/history,须由 engine 在同一 Root 组织其它修改。 */
|
||||||
retire(recoveryId:Id,decisions:readonly {operationId:Id;resolution:'superseded'|'discarded'|'satisfied';replacementId?:Id}[]) {
|
retire(recoveryId:Id,decisions:readonly {operationId:Id;resolution:'superseded'|'discarded'|'satisfied';replacementId?:Id}[]) {
|
||||||
const ids=new Set(decisions.map(decision=>decision.operationId));
|
const ids=new Set(decisions.map(decision=>decision.operationId));
|
||||||
if(!recoveryId||ids.size!==decisions.length||new Set(decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])).size!==decisions.filter(decision=>decision.replacementId).length)throw fault('invalid_recovery_decisions');
|
if(!recoveryId||ids.size!==decisions.length||new Set(decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])).size!==decisions.filter(decision=>decision.replacementId).length)throw fault('invalid_recovery_decisions');
|
||||||
@@ -175,17 +184,20 @@ export function createLedger(initial?: LedgerSnapshot) {
|
|||||||
for(const decision of decisions)operations.set(decision.operationId,freeze({...operations.get(decision.operationId)!,phase:'resolved',resolution:decision.resolution,recoveryId,...(decision.replacementId?{replacementId:decision.replacementId}:{})}));
|
for(const decision of decisions)operations.set(decision.operationId,freeze({...operations.get(decision.operationId)!,phase:'resolved',resolution:decision.resolution,recoveryId,...(decision.replacementId?{replacementId:decision.replacementId}:{})}));
|
||||||
if(decisions.length)changed();
|
if(decisions.length)changed();
|
||||||
},
|
},
|
||||||
/** 保存原请求的 dueAt/action,重试计划也是可恢复事实;实际 heap 是 scheduler 的派生索引。 */
|
/** 保存原请求的 dueAt/action;这里只要求 dueAt 有限,不要求大于当前时间。
|
||||||
|
* 重试计划是可恢复事实,实际 heap/timer 是 scheduler 的派生索引;这里不启动 timer。 */
|
||||||
scheduleRetry(id: Id, dueAt: number, action: 'submit' | 'query') {
|
scheduleRetry(id: Id, dueAt: number, action: 'submit' | 'query') {
|
||||||
const request = requireRequest(id);
|
const request = requireRequest(id);
|
||||||
if (request.phase === 'resolved' || !Number.isFinite(dueAt)) throw fault('invalid_retry', [id]);
|
if (request.phase === 'resolved' || !Number.isFinite(dueAt)) throw fault('invalid_retry', [id]);
|
||||||
requests.set(id, freeze({ ...request, retryAt: dueAt, retryAction: action })); changed();
|
requests.set(id, freeze({ ...request, retryAt: dueAt, retryAction: action })); changed();
|
||||||
},
|
},
|
||||||
/** 为未解决请求记录解释器/权限等阻碍,并清重试计划;保留 body/key 和 claims 的恢复依据。 */
|
/** 为未解决请求记录解释器/权限等阻碍,发生写入时清 retry 元数据,保留 body/key。
|
||||||
|
* 已 resolved 或 error 相同直接 no-op;相同 error 的早返回不会再清已有 retry 元数据。 */
|
||||||
blockRequest(id: Id, error: string) { const request = requireRequest(id); if (request.phase === 'resolved' || request.error === error) return; requests.set(id, freeze({ ...request, error, retryAt: undefined, retryAction: undefined })); changed(); },
|
blockRequest(id: Id, error: string) { const request = requireRequest(id); if (request.phase === 'resolved' || request.error === error) return; requests.set(id, freeze({ ...request, error, retryAt: undefined, retryAction: undefined })); changed(); },
|
||||||
/** 仅允许 durable 水位单调前进到当前 generation 内;不新增业务 generation。 */
|
/** 仅允许 durable 水位单调前进到当前 generation 内;不新增业务 generation。 */
|
||||||
markDurable(through: number) { if (!Number.isSafeInteger(through) || through < durableGeneration || through > generation) throw fault('invalid_durable_generation'); durableGeneration = through; refresh(); },
|
markDurable(through: number) { if (!Number.isSafeInteger(through) || through < durableGeneration || through > generation) throw fault('invalid_durable_generation'); durableGeneration = through; refresh(); },
|
||||||
/** 发送标记后且未确定 not-applied 均按可能已执行处理;这是保守风险依据,不是成功证明。 */
|
/** 精确条件为 phase!=frozen 且 outcome!=not-applied,按可能已执行处理;applied/resolved 也可为 true。
|
||||||
|
* 这是基于账本标记的保守风险读取,不查询网络,不证明成功,也不取代实际发送前持久化屏障。 */
|
||||||
possiblyApplied(id: Id) { const request = requireRequest(id); return request.phase !== 'frozen' && request.outcome !== 'not-applied'; },
|
possiblyApplied(id: Id) { const request = requireRequest(id); return request.phase !== 'frozen' && request.outcome !== 'not-applied'; },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 从 ledger 派生执行顺序的调度器:ready FIFO+queued 去重、依赖索引、canvas claims 与重试 min-heap。
|
* 从 ledger 派生执行顺序的调度器:ready FIFO+queued 去重、依赖索引、canvas claims 与重试 min-heap。
|
||||||
* 所有权威变化通过 options.mutate 写回 Root;队列本身既不是历史,也不保存另一套任务状态。
|
* 所有权威变化通过 options.mutate 写回 Root;队列本身既不是历史,也不保存另一套任务状态。
|
||||||
* 发送前先持久化精确 body/key 和 dispatch 标记,结果协调及持久化成功后才释放占用。
|
* 发送前等待宿主持久化精确 body/key 和 dispatch 标记,结果协调及持久化成功后才释放占用。
|
||||||
|
* claims/holds 只约束本 scheduler;跨标签、进程或服务端的 writer/身份隔离由 engine 与 storage 端口负责。
|
||||||
*/
|
*/
|
||||||
import type { Id, Json, OperationIntent } from '../canvas-core/types.ts';
|
import type { Id, Json, OperationIntent } from '../canvas-core/types.ts';
|
||||||
import { fault, readMap } from '../canvas-core/values.ts';
|
import { fault, readMap } from '../canvas-core/values.ts';
|
||||||
@@ -21,27 +22,40 @@ type Ledger = ReturnType<typeof createLedger>;
|
|||||||
/** engine 提供准入、纯编译、原子账本更新、持久屏障、外部端口和完成协调。
|
/** engine 提供准入、纯编译、原子账本更新、持久屏障、外部端口和完成协调。
|
||||||
* complete 必须连回执/基线/working/ledger 一起处理,不允许仅把请求先标成功。 */
|
* complete 必须连回执/基线/working/ledger 一起处理,不允许仅把请求先标成功。 */
|
||||||
export type SchedulerOptions = {
|
export type SchedulerOptions = {
|
||||||
|
/** false 进入准入等待集合,由 refreshAdmission 唤醒;不在此把逻辑操作改为 blocked。 */
|
||||||
admit?: (intent:OperationIntent)=>boolean;
|
admit?: (intent:OperationIntent)=>boolean;
|
||||||
/** 每次准入/唤醒读取当前权威快照,不信任入队时捕获的旧状态。 */
|
/** 每次准入/唤醒读取当前权威快照,不信任入队时捕获的旧状态。 */
|
||||||
getLedger(): LedgerSnapshot;
|
getLedger(): LedgerSnapshot;
|
||||||
/** 在 engine 的受控发布路径内修改账本,失败不能留下半份持久事实。 */
|
/** 在 engine 的受控发布路径内修改账本,失败不能留下半份持久事实。 */
|
||||||
mutate(reduce: (ledger: Ledger) => void): void;
|
mutate(reduce: (ledger: Ledger) => void): void;
|
||||||
/** 等待当前 Root 可靠存储;失败保持请求身份与占用,不退回生成新请求。 */
|
/** 等待当前 Root 可靠存储;失败保持请求身份与占用,不退回生成新请求。
|
||||||
|
* scheduler 信任此 Promise 的持久承诺,本身不核对 durableGeneration/底层事务。 */
|
||||||
persist(): Promise<void>;
|
persist(): Promise<void>;
|
||||||
|
/** 为新冻结请求分配 ID;恢复/重试沿用原 ID,不再次调用此工厂。 */
|
||||||
createId(): Id;
|
createId(): Id;
|
||||||
|
/** 从当前意图推导需要 claim 的 canvas;编译结果必须覆盖同一集合。 */
|
||||||
participants(intent: OperationIntent): readonly Id[];
|
participants(intent: OperationIntent): readonly Id[];
|
||||||
|
/** 同步编译一个意图;不在这里发送,payload/版本随后由 ledger.freeze 捕获。 */
|
||||||
compile(intent: OperationIntent, requestId: Id): CompiledRequest;
|
compile(intent: OperationIntent, requestId: Id): CompiledRequest;
|
||||||
|
/** 将选中的连续候选缩为非空前缀,返回值必须是 1..intents.length 的整数。 */
|
||||||
batchLimit?: (intents: readonly OperationIntent[]) => number;
|
batchLimit?: (intents: readonly OperationIntent[]) => number;
|
||||||
|
/** 可选抵消协调钩子:先实际标记 ledger,再返回已抵消的候选前缀 ID;调度器不自行构造证明。 */
|
||||||
elide?: (intents: readonly OperationIntent[]) => readonly Id[];
|
elide?: (intents: readonly OperationIntent[]) => readonly Id[];
|
||||||
|
/** 多意图编译器仅在最终候选数 >1 时使用,单项仍走 compile。 */
|
||||||
compileBatch?: (intents: readonly OperationIntent[], requestId: Id) => CompiledRequest;
|
compileBatch?: (intents: readonly OperationIntent[], requestId: Id) => CompiledRequest;
|
||||||
|
/** 使用保存的冻结请求发写端口;端口负责协议/权限与回执解释,不得重编 payload。 */
|
||||||
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||||
|
/** 按原请求身份查结果,端口解释回执;端口抛错时 scheduler 统一记为 unresolved/transport_unknown。 */
|
||||||
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||||
|
/** 恢复时确认冻结格式是否有解释器;省略钩子只默认支持 protocolVersion=1。 */
|
||||||
supportsRequest?: (request: FrozenRequest) => boolean;
|
supportsRequest?: (request: FrozenRequest) => boolean;
|
||||||
/** Atomically reconcile the receipt/base/working document and the ledger outcome. */
|
/** 同步协调回执/base/working 与 ledger 的宿主边界;不得返回异步准备结果。 */
|
||||||
complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void;
|
complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void;
|
||||||
|
/** 报告存储/协调/编译错误,scheduler 隔离此回调抛错,不能用它改写请求结果。 */
|
||||||
onError(error: unknown): void;
|
onError(error: unknown): void;
|
||||||
clock?: Clock;
|
clock?: Clock;
|
||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
|
/** 按 submit attempt 提供有限退避毫秒;正常值取至少 1ms,端口 retryAfterMs 可覆盖此次值。 */
|
||||||
retryDelay?: (attempt: number) => number;
|
retryDelay?: (attempt: number) => number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +72,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
const waiters = new Map<Id, Set<Id>>(), dependents = new Map<Id, Set<Id>>(), indexed = new Set<Id>();
|
const waiters = new Map<Id, Set<Id>>(), dependents = new Map<Id, Set<Id>>(), indexed = new Set<Id>();
|
||||||
// 最早 dueAt 优先;同到期按入堆 sequence 稳定排序,token 让旧 timer/旧重试条目惰性失效。
|
// 最早 dueAt 优先;同到期按入堆 sequence 稳定排序,token 让旧 timer/旧重试条目惰性失效。
|
||||||
const retry = createMinHeap<{ id: Id; dueAt: number; sequence: number; token: number }>((a, b) => a.dueAt - b.dueAt || a.sequence - b.sequence);
|
const retry = createMinHeap<{ id: Id; dueAt: number; sequence: number; token: number }>((a, b) => a.dueAt - b.dueAt || a.sequence - b.sequence);
|
||||||
// active 只跟踪当前异步句柄/AbortController;任务完成事实仍必须回到 ledger。
|
// active 跟踪整条异步流水线(包含发送前/后的持久化等待),不是仅统计正在进行的 HTTP 请求。
|
||||||
|
// 句柄/AbortController 属于调度器,任务完成事实仍必须回到 ledger。
|
||||||
const tokens = new Map<Id, number>(), active = new Map<Id, { controller: AbortController; promise: Promise<void>; kind: FrozenRequest['kind'] }>();
|
const tokens = new Map<Id, number>(), active = new Map<Id, { controller: AbortController; promise: Promise<void>; kind: FrozenRequest['kind'] }>();
|
||||||
// 多个恢复/关闭流程可独立 hold;只有最后一个 token 释放才继续准入,已启动工作仍正常收尾。
|
// 多个恢复/关闭流程可独立 hold;只有最后一个 token 释放才继续准入,已启动工作仍正常收尾。
|
||||||
const holds = new Set<symbol>();
|
const holds = new Set<symbol>();
|
||||||
@@ -73,7 +88,7 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
const report = (error: unknown) => { try { options.onError(error); } catch { /* Reporting cannot change a receipt. */ } };
|
const report = (error: unknown) => { try { options.onError(error); } catch { /* Reporting cannot change a receipt. */ } };
|
||||||
/** 旧冻结协议必须有解释器才重试;缺钩子时仅默认支持协议 1。 */
|
/** 旧冻结协议必须有解释器才重试;缺钩子时仅默认支持协议 1。 */
|
||||||
const supported = (request: FrozenRequest) => options.supportsRequest?.(request) ?? request.protocolVersion === 1;
|
const supported = (request: FrozenRequest) => options.supportsRequest?.(request) ?? request.protocolVersion === 1;
|
||||||
/** 总并发上限之外,上传独占一个 lane;其它类别仍可使用剩余网络执行位。 */
|
/** 总 active 上限之外,asset.upload 至多占一个 lane;其它类别可用剩余流水线执行位。 */
|
||||||
const canRun = (kind: FrozenRequest['kind']) => active.size < concurrency && (kind !== 'asset.upload' || ![...active.values()].some(task => task.kind === 'asset.upload'));
|
const canRun = (kind: FrozenRequest['kind']) => active.size < concurrency && (kind !== 'asset.upload' || ![...active.values()].some(task => task.kind === 'asset.upload'));
|
||||||
|
|
||||||
/** 先读 ledger 检查 pending/依赖/门禁,再用 queued 去重入 FIFO;不是通知一次就盲发一次。 */
|
/** 先读 ledger 检查 pending/依赖/门禁,再用 queued 去重入 FIFO;不是通知一次就盲发一次。 */
|
||||||
@@ -100,7 +115,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
enqueue(id);
|
enqueue(id);
|
||||||
}
|
}
|
||||||
/** Only a contiguous mutable document prefix may share a request. Task barriers stay visible. */
|
/** Only a contiguous mutable document prefix may share a request. Task barriers stay visible. */
|
||||||
/** 只选连续未冻结 document.change 前缀,最多 64 意图/10000 changes,遇任务屏障或占用即停。
|
/** 只选连续未冻结 document.change 前缀,最多 64 意图;追加候选时不让累计 changes 超过 10000。
|
||||||
|
* 首意图本身超过 10000 时仍可单独返回,不把它拆成字段请求;遇任务屏障/占用即停。
|
||||||
* 仅合并有相交参与者的 lane;独立 canvas 保留各自失败/重试边界。 */
|
* 仅合并有相交参与者的 lane;独立 canvas 保留各自失败/重试边界。 */
|
||||||
function batch(first: OperationIntent, canvases: Set<Id>): readonly OperationIntent[] {
|
function batch(first: OperationIntent, canvases: Set<Id>): readonly OperationIntent[] {
|
||||||
const selected = [first], ids = new Set([first.id]);
|
const selected = [first], ids = new Set([first.id]);
|
||||||
@@ -143,7 +159,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
}
|
}
|
||||||
for (const id of request.operationIds) for (const dependent of dependents.get(id) ?? []) enqueue(dependent);
|
for (const id of request.operationIds) for (const dependent of dependents.get(id) ?? []) enqueue(dependent);
|
||||||
}
|
}
|
||||||
/** 可选写回可恢复 retry 元数据,再用新 token 入 heap;重建索引时 persist=false 避免重复写事实。 */
|
/** 可选 mutate 写回 retry 元数据,再用新 token 入 heap;persist=false 仅表示不重复写账本。
|
||||||
|
* 此处没有 await 存储屏障,真正可靠落盘仍由 launch/resume 的 options.persist 完成。 */
|
||||||
function schedule(id: Id, dueAt: number, action: 'submit' | 'query', persist = true) {
|
function schedule(id: Id, dueAt: number, action: 'submit' | 'query', persist = true) {
|
||||||
if (persist) options.mutate(ledger => ledger.scheduleRetry(id, dueAt, action));
|
if (persist) options.mutate(ledger => ledger.scheduleRetry(id, dueAt, action));
|
||||||
const token = (tokens.get(id) ?? 0) + 1; tokens.set(id, token);
|
const token = (tokens.get(id) ?? 0) + 1; tokens.set(id, token);
|
||||||
@@ -160,7 +177,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
if (stopped || holds.size || active.size >= concurrency || !retry.peek()) return;
|
if (stopped || holds.size || active.size >= concurrency || !retry.peek()) return;
|
||||||
timer = clock.setTimer(() => { timer = undefined; pump(); }, Math.max(0, retry.peek()!.dueAt - clock.now()));
|
timer = clock.setTimer(() => { timer = undefined; pump(); }, Math.max(0, retry.peek()!.dueAt - clock.now()));
|
||||||
}
|
}
|
||||||
/** 安装 active 句柄后在微任务中执行:dispatch 标记→持久化→submit/query→原子协调→持久化。 */
|
/** 安装 active 句柄后在微任务中执行:submit 先标 dispatch;query 保留现有发送风险,再共同等待持久化。
|
||||||
|
* 调端口→同步 complete→再次持久化;hold 不撤回已启动流水线,stop 则让晚到结果不再采纳。 */
|
||||||
function launch(id: Id, action: 'submit' | 'query') {
|
function launch(id: Id, action: 'submit' | 'query') {
|
||||||
if (active.has(id) || stopped || holds.size) return;
|
if (active.has(id) || stopped || holds.size) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -186,6 +204,7 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
else {
|
else {
|
||||||
// A missing receipt is unknown, so the only write retry reuses this frozen request.
|
// A missing receipt is unknown, so the only write retry reuses this frozen request.
|
||||||
// 首次 submit 未知先 query;query 仍未知才原样重试 submit;accepted 一直查,不换请求身份。
|
// 首次 submit 未知先 query;query 仍未知才原样重试 submit;accepted 一直查,不换请求身份。
|
||||||
|
// accepted 的查询/submit 都继续 query;其它 submit 结果先 query,非 accepted 的 query 结果才允许同身份 submit。
|
||||||
const nextAction = status.outcome === 'accepted' || action === 'submit' ? 'query' : 'submit';
|
const nextAction = status.outcome === 'accepted' || action === 'submit' ? 'query' : 'submit';
|
||||||
schedule(id, clock.now() + Math.max(1, status.retryAfterMs ?? delay(current.attempt)), nextAction);
|
schedule(id, clock.now() + Math.max(1, status.retryAfterMs ?? delay(current.attempt)), nextAction);
|
||||||
await options.persist();
|
await options.persist();
|
||||||
@@ -203,7 +222,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
});
|
});
|
||||||
active.set(id, { controller, promise, kind: options.getLedger().requests.get(id)?.kind });
|
active.set(id, { controller, promise, kind: options.getLedger().requests.get(id)?.kind });
|
||||||
}
|
}
|
||||||
/** 可重入保护下消费到期请求和最多 64 个 ready 项;等待者移到索引,不在 FIFO 无界反复轮询。 */
|
/** 防同步重入地处理到期请求,再从本轮 ready 前缀最多取 64 项;retry 循环没有同样的 64 项上限。
|
||||||
|
* 等待者移到索引,ready 尚有余量且有容量时安排下一轮 timer,不代表总队列大小有上限。 */
|
||||||
function pump() {
|
function pump() {
|
||||||
if (stopped || holds.size || pumping) return;
|
if (stopped || holds.size || pumping) return;
|
||||||
pumping = true;
|
pumping = true;
|
||||||
@@ -250,7 +270,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
}
|
}
|
||||||
const intents = options.compileBatch ? candidates : [record.intent];
|
const intents = options.compileBatch ? candidates : [record.intent];
|
||||||
const participants = [...new Set(intents.flatMap(intent => options.participants(intent)))].sort();
|
const participants = [...new Set(intents.flatMap(intent => options.participants(intent)))].sort();
|
||||||
// 编译参与者必须与规划一致,随后 freeze/own/launch;失败阻塞原意图而非偷偷重新编另一请求。
|
// 编译参与者必须与实际 intents 推导的集合一致,随后 freeze/own/launch;没有 compileBatch 时只编译首意图。
|
||||||
|
// 异常走原逻辑 ID 的 block;若已 frozen,ledger.block 不会把它退回 pending,冻结身份仍保留。
|
||||||
const requestId = options.createId(), compiled = intents.length > 1 ? options.compileBatch!(intents, requestId) : options.compile(record.intent, requestId);
|
const requestId = options.createId(), compiled = intents.length > 1 ? options.compileBatch!(intents, requestId) : options.compile(record.intent, requestId);
|
||||||
if (participants.length !== compiled.canvasIds.length || participants.some(canvas => !compiled.canvasIds.includes(canvas))) throw fault('participant_mismatch');
|
if (participants.length !== compiled.canvasIds.length || participants.some(canvas => !compiled.canvasIds.includes(canvas))) throw fault('participant_mismatch');
|
||||||
options.mutate(ledger => ledger.freeze({ ...compiled, id: requestId, operationIds: intents.map(intent => intent.id) }));
|
options.mutate(ledger => ledger.freeze({ ...compiled, id: requestId, operationIds: intents.map(intent => intent.id) }));
|
||||||
@@ -267,7 +288,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 首次启动(已运行时幂等):先恢复全部未解决 claims,按原 retry/body/key 继续;解释器缺失仍保留占用。 */
|
/** 首次启动(已运行时幂等):先恢复全部未解决 claims,之后才开始端口工作;claim 冲突会在仍 stopped 时抛错。
|
||||||
|
* 解释器缺失保留占用并写 blocker;此步骤不做完整恢复 DTO 校验,也不从单个错误推断请求未执行。 */
|
||||||
start() {
|
start() {
|
||||||
if (closed) throw fault('scheduler_closed');
|
if (closed) throw fault('scheduler_closed');
|
||||||
if (!stopped) return;
|
if (!stopped) return;
|
||||||
@@ -284,7 +306,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
},
|
},
|
||||||
/** 门禁事实变化后显式重试准入,未满足者继续等待,不制造定时器忙轮询。 */
|
/** 门禁事实变化后显式重试准入,未满足者继续等待,不制造定时器忙轮询。 */
|
||||||
refreshAdmission(){for(const id of [...admissionWaiters])enqueue(id);if(ready.size)requestPump();},
|
refreshAdmission(){for(const id of [...admissionWaiters])enqueue(id);if(ready.size)requestPump();},
|
||||||
/** 新逻辑操作通知只推进派生索引,重复 notify 不重复注册依赖或入队。 */
|
/** 新逻辑操作通知推进派生索引;正在 queued 时不重复入队,indexed 让依赖只注册一次。
|
||||||
|
* 已出队的 pending 操作后续 notify 可再次尝试准入;通知本身不新增 ledger 意图。 */
|
||||||
notify(operationIds: readonly Id[]) { for (const id of operationIds) index(id); requestPump(); },
|
notify(operationIds: readonly Id[]) { for (const id of operationIds) index(id); requestPump(); },
|
||||||
/** Acquire synchronously, then settle(): already admitted work completes with its original identity. */
|
/** Acquire synchronously, then settle(): already admitted work completes with its original identity. */
|
||||||
/** 同步阻止新准入并清唤醒 timer;返回幂等释放函数,已准入异步工作不被撤销或换身份。 */
|
/** 同步阻止新准入并清唤醒 timer;返回幂等释放函数,已准入异步工作不被撤销或换身份。 */
|
||||||
@@ -316,7 +339,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
},
|
},
|
||||||
/** 返回当前占用表的只读副本,调用者不能手改 claim 绕过未知请求。 */
|
/** 返回当前占用表的只读副本,调用者不能手改 claim 绕过未知请求。 */
|
||||||
getClaims: () => readMap(claims),
|
getClaims: () => readMap(claims),
|
||||||
/** 等待当前微任务/pump/active 收尾,不主动等待未来 retry timer,因此不是“全部已同步”保证。 */
|
/** 等待当前微任务/pump/active 收尾,不主动等待未来 retry timer,因此不是“全部已同步”保证。
|
||||||
|
* 端口若长期不完成(包括忽略 abort),active Promise 仍可让 settle 一直等待。 */
|
||||||
async settle() {
|
async settle() {
|
||||||
do {
|
do {
|
||||||
if (scheduledPump) await scheduledPump;
|
if (scheduledPump) await scheduledPump;
|
||||||
@@ -324,7 +348,8 @@ export function createScheduler(options: SchedulerOptions) {
|
|||||||
if (!stopped && !holds.size && pumpTimer !== undefined) { clock.clearTimer(pumpTimer); pumpTimer = undefined; pump(); }
|
if (!stopped && !holds.size && pumpTimer !== undefined) { clock.clearTimer(pumpTimer); pumpTimer = undefined; pump(); }
|
||||||
} while (scheduledPump || active.size || (!stopped && !holds.size && pumpTimer !== undefined));
|
} while (scheduledPump || active.size || (!stopped && !holds.size && pumpTimer !== undefined));
|
||||||
},
|
},
|
||||||
/** 永久关闭本 scheduler、取消 timer 并 abort active;晚到响应因 stopped 不再采纳,不能再 start。 */
|
/** 永久关闭本 scheduler、取消 timer 并请求 abort active;晚到响应因 stopped 不再采纳,不能再 start。
|
||||||
|
* abort 不证明外部未执行,也不清 ledger/claims/派生容器或替 engine 关闭存储;活动句柄在 finally 收尾。 */
|
||||||
stop() { closed = true; stopped = true; if (timer !== undefined) clock.clearTimer(timer); if (pumpTimer !== undefined) clock.clearTimer(pumpTimer); timer = pumpTimer = undefined; for (const item of active.values()) item.controller.abort(); },
|
stop() { closed = true; stopped = true; if (timer !== undefined) clock.clearTimer(timer); if (pumpTimer !== undefined) clock.clearTimer(pumpTimer); timer = pumpTimer = undefined; for (const item of active.values()) item.controller.abort(); },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* runtime 使用的基础调度容器,不包含任务状态事实:FIFO 存可执行顺序,min-heap 存优先项。
|
* runtime 使用的基础调度容器,不包含任务状态事实:FIFO 存可执行顺序,min-heap 存优先项。
|
||||||
* 两者都是内存结构,ledger/checkpoint 才负责可恢复事实;容器本身不去重、不校验依赖或执行网络。
|
* 两者都是内存结构,ledger/checkpoint 才负责可恢复事实;容器本身不去重、不校验依赖或执行网络。
|
||||||
|
* T 值按引用保存/返回,不复制冻结任务对象,也不以 size/clear 修改外部任务事实。
|
||||||
*/
|
*/
|
||||||
/** FIFO with amortized O(1) removal, without Array.shift on each item. */
|
/** FIFO with amortized O(1) removal, without Array.shift on each item. */
|
||||||
/** 当前接口是先进先出队列:push 入尾、pop 出头,并非 history 的后进先出 stack。
|
/** 当前接口是先进先出队列:push 入尾、pop 出头,并非 history 的后进先出 stack。
|
||||||
@@ -24,12 +25,13 @@ export function createDeque<T>() {
|
|||||||
},
|
},
|
||||||
/** 同时丢弃底层数组和游标,释放旧任务引用。 */
|
/** 同时丢弃底层数组和游标,释放旧任务引用。 */
|
||||||
clear() { items = []; head = 0; },
|
clear() { items = []; head = 0; },
|
||||||
/** 返回有效区间的数组副本,外部改副本不会更改队列成员。 */
|
/** 返回有效区间的浅数组副本,改副本成员位置不改队列;对象成员仍共享引用。 */
|
||||||
values() { return items.slice(head); },
|
values() { return items.slice(head); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/** 数组实现二叉最小堆,compare<0 表示左项优先;peek O(1),push/pop O(log n)。
|
/** 数组实现二叉最小堆,compare<0 表示左项优先;peek O(1),push/pop O(log n)。
|
||||||
* 相等优先级不保证插入稳定性,上层若需要 FIFO tie-break 必须把序列写进 comparator。 */
|
* 相等优先级不保证插入稳定性,上层若需要 FIFO tie-break 必须把序列写进 comparator。
|
||||||
|
* compare 应稳定、同步且不抛错;容器不校验 comparator,比较中途异常也不提供事务回滚。 */
|
||||||
export function createMinHeap<T>(compare: (left: T, right: T) => number) {
|
export function createMinHeap<T>(compare: (left: T, right: T) => number) {
|
||||||
// 父下标 (i-1)>>1,左右孩子 2i+1/2i+2;堆序只要求父不大于孩子,不是全数组排序。
|
// 父下标 (i-1)>>1,左右孩子 2i+1/2i+2;堆序只要求父不大于孩子,不是全数组排序。
|
||||||
const items: T[] = [];
|
const items: T[] = [];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 assert from "node:assert/strict";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { readdir, readFile } from "node:fs/promises";
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
// 应用使用固定端口;upstream 另选随机端口,由子进程环境变量指定为代理源站。
|
||||||
const host = "http://127.0.0.1:3005";
|
const host = "http://127.0.0.1:3005";
|
||||||
|
// 图谱、画布版本、会话剩余秒数与 profile 都是本次进程的内存夹具。
|
||||||
const graphs = new Map();
|
const graphs = new Map();
|
||||||
let releaseStream = () => {};
|
let releaseStream = () => {};
|
||||||
let canvasSnapshot = { version: 1, document: { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } };
|
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",
|
id: "user", username: "test", display_name: "Test User", email: "test@example.com",
|
||||||
avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c",
|
avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c",
|
||||||
};
|
};
|
||||||
|
// 最小 Foundry HTTP 替身:有效测试会话才发续期提示,并按路由返回页面代理需要的响应。
|
||||||
const upstream = createServer(async (req, res) => {
|
const upstream = createServer(async (req, res) => {
|
||||||
res.setHeader("Content-Type", "application/json");
|
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);
|
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");
|
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");
|
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.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.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; }
|
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]);
|
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;
|
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")) {
|
if (canonical.endsWith("/materials/slow-body-board/canvas")) {
|
||||||
res.setHeader("ETag", '\"210\"');
|
res.setHeader("ETag", '\"210\"');
|
||||||
res.write('{"version":210,"document":');
|
res.write('{"version":210,"document":');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 15200));
|
await new Promise((resolve) => setTimeout(resolve, 15200));
|
||||||
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
|
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
|
||||||
}
|
}
|
||||||
|
// 由 releaseStream 控制尾部放行:代理必须在完整 JSON 可用之前转交头和首块。
|
||||||
if (canonical.endsWith("/materials/stream-board/canvas")) {
|
if (canonical.endsWith("/materials/stream-board/canvas")) {
|
||||||
assert.equal(req.headers.authorization, "Bearer test-only-session");
|
assert.equal(req.headers.authorization, "Bearer test-only-session");
|
||||||
res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2");
|
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; });
|
await new Promise((resolve) => { releaseStream = resolve; });
|
||||||
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
|
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
|
||||||
}
|
}
|
||||||
|
// V2 子命令只回显 URL、方法与关键头;这个分支不执行真正的连接、AI 或结构事务。
|
||||||
if (canonical.includes("/canvas/")) {
|
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;
|
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 (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.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; }
|
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 };
|
canvasSnapshot = { version: canvasSnapshot.version + 1, document };
|
||||||
res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify({ version: canvasSnapshot.version })); return;
|
res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify({ version: canvasSnapshot.version })); return;
|
||||||
}
|
}
|
||||||
|
// 其余分支提供登出、图谱创建/重命名、登录 token 和 profile;未知路由返回 404。
|
||||||
if (req.url.endsWith("/auth/logout")) {
|
if (req.url.endsWith("/auth/logout")) {
|
||||||
res.statusCode = 204;
|
res.statusCode = 204;
|
||||||
res.end();
|
res.end();
|
||||||
@@ -59,6 +74,7 @@ const upstream = createServer(async (req, res) => {
|
|||||||
for await (const chunk of req) raw += chunk;
|
for await (const chunk of req) raw += chunk;
|
||||||
const input = JSON.parse(raw);
|
const input = JSON.parse(raw);
|
||||||
assert.deepEqual(input, { kind: "board", title: "未命名图谱", content: { schema_version: 1 } });
|
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 };
|
const graph = { ...input, slug: "my-board", id: "foundry-generated-id", workspace_id: "space", version: 1 };
|
||||||
graphs.set(graph.id, graph);
|
graphs.set(graph.id, graph);
|
||||||
res.statusCode = 201;
|
res.statusCode = 201;
|
||||||
@@ -100,8 +116,10 @@ const upstream = createServer(async (req, res) => {
|
|||||||
res.end("{}");
|
res.end("{}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 先监听随机端口再启动 Next 子进程,保证应用首次代理请求就能连到替身。
|
||||||
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
||||||
const port = upstream.address().port;
|
const port = upstream.address().port;
|
||||||
|
// 启动 next start 消费已有构建,捕获 stdout/stderr 供启动失败诊断,不运行开发服务器。
|
||||||
const app = spawn(
|
const app = spawn(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[
|
[
|
||||||
@@ -118,10 +136,12 @@ const app = spawn(
|
|||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// 收集子进程启动输出;轮询中若提前退出,错误包含这些诊断文本。
|
||||||
let startup = "";
|
let startup = "";
|
||||||
app.stdout.on("data", (x) => (startup += x));
|
app.stdout.on("data", (x) => (startup += x));
|
||||||
app.stderr.on("data", (x) => (startup += x));
|
app.stderr.on("data", (x) => (startup += x));
|
||||||
try {
|
try {
|
||||||
|
// 最多轮询 100 次,失败后等待 100ms,再检查首页是否可用;不能只凭 spawn 返回就宣称服务已启动。
|
||||||
let ready = false;
|
let ready = false;
|
||||||
for (let n = 0; n < 100; n++) {
|
for (let n = 0; n < 100; n++) {
|
||||||
if (app.exitCode !== null)
|
if (app.exitCode !== null)
|
||||||
@@ -135,6 +155,7 @@ try {
|
|||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
assert(ready, "Frontend did not start");
|
assert(ready, "Frontend did not start");
|
||||||
|
// 逐页检查状态和公共元信息;这些是服务端 HTML 断言,不代表页面客户端交互已验证。
|
||||||
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1", "/dashboard/space/foundry-generated-id"]) {
|
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1", "/dashboard/space/foundry-generated-id"]) {
|
||||||
const page = await fetch(host + path);
|
const page = await fetch(host + path);
|
||||||
assert.equal(page.status, 200, path);
|
assert.equal(page.status, 200, path);
|
||||||
@@ -146,6 +167,7 @@ try {
|
|||||||
assert(html.includes('/brand/icons/favicon.ico'));
|
assert(html.includes('/brand/icons/favicon.ico'));
|
||||||
assert(html.includes('/brand/icons/apple-touch-icon.png'));
|
assert(html.includes('/brand/icons/apple-touch-icon.png'));
|
||||||
}
|
}
|
||||||
|
// 逐个下载本地品牌资产并与仓库原字节比较,检查静态服务没有替换或遗漏文件。
|
||||||
const brandRoot = new URL("../public/brand/", import.meta.url);
|
const brandRoot = new URL("../public/brand/", import.meta.url);
|
||||||
const assets = (await readdir(brandRoot, { recursive: true })).filter((file) => /\.(png|ico|svg)$/.test(file));
|
const assets = (await readdir(brandRoot, { recursive: true })).filter((file) => /\.(png|ico|svg)$/.test(file));
|
||||||
assert.equal(assets.length, 88);
|
assert.equal(assets.length, 88);
|
||||||
@@ -154,6 +176,7 @@ try {
|
|||||||
assert.equal(response.status, 200, asset);
|
assert.equal(response.status, 200, asset);
|
||||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), await readFile(new URL(asset, brandRoot)), 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"));
|
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)) {
|
for (const file of Object.values(iconManifest.assets)) {
|
||||||
const path = iconManifest.root + "/" + file;
|
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.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);
|
assert.equal((await fetch(host + "/missing-page")).status, 404);
|
||||||
|
// 登录代理必须去掉响应体 token,改设 HttpOnly/SameSite cookie,并报告上游计时。
|
||||||
const res = await fetch(host + "/api/foundry/auth/login", {
|
const res = await fetch(host + "/api/foundry/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Origin: host },
|
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); }
|
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);
|
assert.equal(JSON.parse(raw).document.schema_version, 2);
|
||||||
} finally { clearTimeout(streamTimer); releaseStream(); }
|
} finally { clearTimeout(streamTimer); releaseStream(); }
|
||||||
|
// 子命令循环只检查头透传;随后检查资源 variant、目录分页与 AI 查询参数白名单。
|
||||||
const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas";
|
const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas";
|
||||||
const idem = "11111111-1111-4111-8111-111111111111";
|
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"]) {
|
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");
|
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 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);
|
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 } });
|
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");
|
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 } });
|
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 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();
|
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);
|
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" });
|
const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" });
|
||||||
assert.equal(signedInHome.status, 307);
|
assert.equal(signedInHome.status, 307);
|
||||||
assert.equal(signedInHome.headers.get("location"), "/dashboard");
|
assert.equal(signedInHome.headers.get("location"), "/dashboard");
|
||||||
@@ -222,6 +249,7 @@ try {
|
|||||||
assert.match(dashboardHtml, /aria-label="(?:工作空间侧边栏|正在加载侧边栏)"/);
|
assert.match(dashboardHtml, /aria-label="(?:工作空间侧边栏|正在加载侧边栏)"/);
|
||||||
assert(!dashboardHtml.includes('data-testid="rf__wrapper"'));
|
assert(!dashboardHtml.includes('data-testid="rf__wrapper"'));
|
||||||
assert(!dashboardHtml.includes('class="landing"'));
|
assert(!dashboardHtml.includes('class="landing"'));
|
||||||
|
// 无会话或过期会话首页保持 landing,避免错误重定向到个人工作区。
|
||||||
for (const guestHeaders of [{}, { Cookie: "foundry_session=expired-session" }]) {
|
for (const guestHeaders of [{}, { Cookie: "foundry_session=expired-session" }]) {
|
||||||
const guestHome = await fetch(host + "/", { headers: guestHeaders, redirect: "manual" });
|
const guestHome = await fetch(host + "/", { headers: guestHeaders, redirect: "manual" });
|
||||||
assert.equal(guestHome.status, 200);
|
assert.equal(guestHome.status, 200);
|
||||||
@@ -240,6 +268,7 @@ try {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
|
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
|
||||||
|
// 续期只接受有效剩余秒数;非法上游值和无效会话不应产生新的 Set-Cookie。
|
||||||
mockSessionSeconds = "3600";
|
mockSessionSeconds = "3600";
|
||||||
const renewed = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } });
|
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/);
|
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" } });
|
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);
|
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();
|
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");
|
assert.equal(profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar=s96-c");
|
||||||
const updated = await fetch(host + "/api/foundry/profile", {
|
const updated = await fetch(host + "/api/foundry/profile", {
|
||||||
@@ -263,6 +293,7 @@ try {
|
|||||||
assert.equal(updatedProfile.display_name, "Updated name");
|
assert.equal(updatedProfile.display_name, "Updated name");
|
||||||
assert.equal(updatedProfile.avatar_url, profile.avatar_url);
|
assert.equal(updatedProfile.avatar_url, profile.avatar_url);
|
||||||
|
|
||||||
|
// 经真实代理创建、读取和重命名内存图谱,检查服务分配身份、内容保留和元信息版本递增。
|
||||||
const create = await fetch(host + "/api/foundry/workspaces/space/materials", {
|
const create = await fetch(host + "/api/foundry/workspaces/space/materials", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
|
headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
|
||||||
@@ -287,6 +318,7 @@ try {
|
|||||||
assert.equal(renamed.version, 2);
|
assert.equal(renamed.version, 2);
|
||||||
assert.deepEqual(renamed.content, stored.content);
|
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"]) {
|
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 path = `/api/foundry/workspaces/${workspace}/materials/${board}`;
|
||||||
const material = await (await fetch(host + path, { headers: { Cookie: session } })).json();
|
const material = await (await fetch(host + path, { headers: { Cookie: session } })).json();
|
||||||
@@ -296,6 +328,7 @@ try {
|
|||||||
assert.equal((await snapshot.json()).version, 1);
|
assert.equal((await snapshot.json()).version, 1);
|
||||||
}
|
}
|
||||||
const canvasPath = "/api/foundry/workspaces/creative-space/materials/my-board/canvas";
|
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 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 }) });
|
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"');
|
assert.equal(savedCanvas.status, 200); assert.equal(savedCanvas.headers.get("etag"), '"2"');
|
||||||
@@ -304,6 +337,7 @@ try {
|
|||||||
assert.deepEqual(restoredCanvas.document, largeDocument);
|
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 }) });
|
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");
|
assert.equal(conflict.status, 412); assert.equal((await conflict.json()).error, "version_conflict");
|
||||||
|
// 旧 frameId 成员关系及三种名称显示模式往返,逐次使用当前画布版本。
|
||||||
const frameDocument = structuredClone(largeDocument);
|
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.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";
|
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 + canvasPath)).status, 401);
|
||||||
assert.equal((await fetch(host + "/api/foundry/workspaces/space/search/my-board/canvas")).status, 404);
|
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) }) });
|
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);
|
assert.equal(oversizedCanvas.status, 413);
|
||||||
|
|
||||||
|
|
||||||
|
// 检查跨源拒绝、路径白名单、OAuth challenge、Secure cookie、非法 JSON、大小上限及方法过滤。
|
||||||
for (const origin of ["https://example.com", "null"])
|
for (const origin of ["https://example.com", "null"])
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
@@ -351,17 +387,20 @@ try {
|
|||||||
assert.equal(invalid.status, status);
|
assert.equal(invalid.status, status);
|
||||||
}
|
}
|
||||||
assert.equal((await fetch(host + "/api/foundry/profile", { method: "PUT" })).status, 405);
|
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", {
|
const materials = await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&limit=100&secret=drop", {
|
||||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}",
|
method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||||
});
|
});
|
||||||
assert.deepEqual(await materials.json(), {
|
assert.deepEqual(await materials.json(), {
|
||||||
url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH",
|
url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH",
|
||||||
});
|
});
|
||||||
|
// OAuth 只检查重定向地址与 challenge,不实际打开提供商或完成授权。
|
||||||
for (const provider of ["google", "slack"]) {
|
for (const provider of ["google", "slack"]) {
|
||||||
const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" });
|
const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" });
|
||||||
assert.equal(redirect.status, 302);
|
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)}`);
|
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", {
|
const logout = await fetch(host + "/api/foundry/auth/logout", {
|
||||||
method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}",
|
method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}",
|
||||||
});
|
});
|
||||||
@@ -371,6 +410,7 @@ try {
|
|||||||
console.log(
|
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",
|
"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 {
|
} finally {
|
||||||
const exited = once(app, "exit");
|
const exited = once(app, "exit");
|
||||||
app.kill("SIGTERM");
|
app.kill("SIGTERM");
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* board 目录分页、树构建、祖先定位与筛选测试。请求由内存函数提供,不访问实际 workspace 服务。
|
||||||
|
* 列表顺序不保证父项先到;孤儿和循环节点仍须可访问,不能递归死循环。
|
||||||
|
*/
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { loadBoardDirectory, boardDirectory, activeBoardAncestors, filterBoardDirectory } from '../src/lib/board-directory.ts';
|
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'});
|
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()=>{
|
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 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'));
|
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.deepEqual(filterBoardDirectory(roots,'grandchild')[0].children[0].children.map(item=>item.board.id),['grandchild']);
|
||||||
assert.equal(filterBoardDirectory(roots,'parent')[0].children.length,1);
|
assert.equal(filterBoardDirectory(roots,'parent')[0].children.length,1);
|
||||||
});
|
});
|
||||||
|
// 父项缺失及双节点循环都提升为可见根项,子列表为空,避免丢失入口或无限嵌套。
|
||||||
test('missing parents and corrupt cycles remain accessible without recursive loops',()=>{
|
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));
|
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()=>{
|
test('directory rejects non-advancing pagination and propagates aborts',async()=>{
|
||||||
await assert.rejects(loadBoardDirectory(async()=>({items:[],next_offset:0}),'w'),/invalid_pagination/);
|
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'});
|
const controller=new AbortController();controller.abort();await assert.rejects(loadBoardDirectory(async(_path,options)=>{options.signal.throwIfAborted();},'w',controller.signal),{name:'AbortError'});
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 旧画布附件上传助手和显示 URL 转换测试。请求为替身,检查 FormData、错误和资源地址,不是真实上传或图像解码。
|
||||||
|
* 这里的上传重试属于 canvas-assets helper,不可等同于 headless ledger 的持久冻结请求恢复。
|
||||||
|
*/
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { uploadCanvasImage } from '../src/lib/canvas-assets.ts';
|
import { uploadCanvasImage } from '../src/lib/canvas-assets.ts';
|
||||||
import { managedImageSource } from '../src/lib/canvas-v2.ts';
|
import { managedImageSource } from '../src/lib/canvas-v2.ts';
|
||||||
|
// 固定 data URL 仅用来构造与对比二进制前缀,并非完整 PNG 解码样本。
|
||||||
const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
|
const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
|
||||||
|
// 固定资产身份;返回值必须是项目管理的地址,任意外域响应不被当作受管上传结果。
|
||||||
const id='77777777-7777-4777-8777-777777777777';
|
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'};
|
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()=>{
|
test('attachment upload sends the original file bytes',async()=>{
|
||||||
const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0;
|
const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0;
|
||||||
const result=await uploadCanvasImage(async(path,options)=>{
|
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');
|
},'w',new Blob([bytes],{type:'image/png'}),'image.png');
|
||||||
assert.equal(calls,1);assert.deepEqual(result,asset);
|
assert.equal(calls,1);assert.deepEqual(result,asset);
|
||||||
});
|
});
|
||||||
|
// 权限拒绝直接传播;格式类似但地址不受管理的成功响应也要拒绝,不能把任意 URL 写成上传结果。
|
||||||
test('attachment upload propagates permission failures and rejects unmanaged response URLs',async()=>{
|
test('attachment upload propagates permission failures and rejects unmanaged response URLs',async()=>{
|
||||||
const file=new Blob(['test'],{type:'image/png'});
|
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()=>{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/);
|
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()=>{
|
test('uncertain upload retries the exact bytes with the same idempotency key',async()=>{
|
||||||
let calls=0,key;
|
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);
|
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',()=>{
|
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(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');
|
assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png');
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 旧 frameId 分区成员模型的拖入、随动与恢复测试;不是 headless ownerCanvasId 树或 linked-board 深子 canvas 的全部规则。
|
||||||
|
* 成员关系由明确 drop 决定,几何重叠本身不代表已经加入分区。
|
||||||
|
*/
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
||||||
import { assignFrameMembership, frameDropFeedback } from "../src/lib/canvas-frames.ts";
|
import { assignFrameMembership, frameDropFeedback } from "../src/lib/canvas-frames.ts";
|
||||||
|
|
||||||
|
// frame、位于内部但尚未登记的 member,以及外部 icon;可分别检查 drop 准入和已有成员随动。
|
||||||
function seeded() {
|
function seeded() {
|
||||||
const frame = makeCanvasNode("frame", { x: 100, y: 100 });
|
const frame = makeCanvasNode("frame", { x: 100, y: 100 });
|
||||||
const member = makeCanvasNode("text", { x: 160, y: 160 });
|
const member = makeCanvasNode("text", { x: 160, y: 160 });
|
||||||
const outsider = makeCanvasNode("icon", { x: 900, y: 120 });
|
const outsider = makeCanvasNode("icon", { x: 900, y: 120 });
|
||||||
return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } };
|
return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } };
|
||||||
}
|
}
|
||||||
|
// 把给定快照包成空历史结构,保持所有测试通过相同 reducer 入口。
|
||||||
function state(document) { return { past: [], present: document, future: [] }; }
|
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", () => {
|
test("drop previews match actual assignment for every content type, exits and overlapping frames", () => {
|
||||||
const { frame } = seeded();
|
const { frame } = seeded();
|
||||||
const smaller = makeCanvasNode("frame", { x: 120, y: 120 }); smaller.width = 300; smaller.height = 240;
|
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", () => {
|
test("only dropped content joins a frame; pre-existing overlapping content stays outside", () => {
|
||||||
const { frame, member, outsider, document } = seeded();
|
const { frame, member, outsider, document } = seeded();
|
||||||
assert.equal(document.nodes[1].data.frameId, undefined);
|
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);
|
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", () => {
|
test("frames carry existing members without collecting newly overlapped content and undo restores positions", () => {
|
||||||
const { frame, member, outsider, document } = seeded();
|
const { frame, member, outsider, document } = seeded();
|
||||||
member.data.frameId = frame.id;
|
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);
|
assert.equal(history.present.nodes[1].data.frameId, frame.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 已有成员拖出清除 frameId,落进另一 frame 则换归属;以同一位置比较两种节点集合。
|
||||||
test("dragging out detaches content and dragging between frames reassigns membership", () => {
|
test("dragging out detaches content and dragging between frames reassigns membership", () => {
|
||||||
const { frame, member } = seeded();
|
const { frame, member } = seeded();
|
||||||
member.data.frameId = frame.id;
|
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);
|
assert.equal(assignFrameMembership([frame, second, member], [member.id])[2].data.frameId, second.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同时上报父 frame 和成员的最终位置时,不能再把父位移叠加到成员,避免双重移动。
|
||||||
test("multi-selection does not move frame members twice", () => {
|
test("multi-selection does not move frame members twice", () => {
|
||||||
const { frame, member, document } = seeded();
|
const { frame, member, document } = seeded();
|
||||||
member.data.frameId = frame.id;
|
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 });
|
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", () => {
|
test("deleting a frame keeps its content and undo restores membership", () => {
|
||||||
const { frame, member, document } = seeded();
|
const { frame, member, document } = seeded();
|
||||||
member.data.frameId = frame.id;
|
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);
|
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", () => {
|
test("all label modes, frame names and memberships survive save/restore and settings undo", () => {
|
||||||
const { frame, member, document } = seeded();
|
const { frame, member, document } = seeded();
|
||||||
member.data.frameId = frame.id;
|
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", () => {
|
test("invalid frame references, label modes, frame nesting and rotation are rejected", () => {
|
||||||
for (const mutate of [
|
for (const mutate of [
|
||||||
({ member }) => { member.data.frameId = "missing"; },
|
({ member }) => { member.data.frameId = "missing"; },
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 旧 CanvasElement 几何与能力测试,直接验证类、纯函数和旧 reducer;不分派真实 pointer 事件。
|
||||||
|
* 多方向/旋转样本用对边锚点及连接端点作为几何不变量,避免只比较宽高变化。
|
||||||
|
*/
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts";
|
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 { nodeConnector, connectorBezier, nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts";
|
||||||
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.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}`); }
|
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 } }; }
|
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", () => {
|
test("card types inherit common element transforms and add branching; plain text inherits only common transforms", () => {
|
||||||
for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) {
|
for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) {
|
||||||
const element = new Type({ x: 0, y: 0 });
|
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"), /纯文本/);
|
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", () => {
|
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"];
|
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 rotation of [0, 37, 90, 180, 270]) {
|
||||||
for (const direction of directions) {
|
for (const direction of directions) {
|
||||||
const node = new TextCardElement({ x: 100, y: 200 }).toNode();
|
const node = new TextCardElement({ x: 100, y: 200 }).toNode();
|
||||||
node.data.rotation = rotation;
|
node.data.rotation = rotation;
|
||||||
|
// hx/hy 表示当前 handle 的局部符号;位移先旋转到世界,再比较缩放前后相反侧锚点的世界位置。
|
||||||
const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
|
const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
|
||||||
const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
|
const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
|
||||||
const delta = rotateVector({ x: hx * 60, y: hy * 40 }, rotation);
|
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", () => {
|
test("plain text width and height resize independently without changing the font", () => {
|
||||||
const node = new PlainTextElement({ x: 0, y: 0 }).toNode();
|
const node = new PlainTextElement({ x: 0, y: 0 }).toNode();
|
||||||
const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 });
|
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);
|
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", () => {
|
test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => {
|
||||||
const node = new TextCardElement({ x: 0, y: 0 }).toNode();
|
const node = new TextCardElement({ x: 0, y: 0 }).toNode();
|
||||||
const center = nodeCenter(node);
|
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);
|
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", () => {
|
test("geometry and plain-text type survive save/restore and transforms undo as one gesture", () => {
|
||||||
const node = new PlainTextElement({ x: 20, y: 30 }).toNode();
|
const node = new PlainTextElement({ x: 20, y: 30 }).toNode();
|
||||||
let state = { past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] };
|
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);
|
close(undo.present.nodes[0].data.rotation, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 旧 material/text 草稿仍解码成卡片且补默认 rotation;负尺寸、超界角度及超大字号逐项拒绝。
|
||||||
test("old text-card drafts remain cards and invalid transform values are rejected", () => {
|
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 old = { id: "old", type: "material", position: { x: 0, y: 0 }, data: { kind: "text", title: "Old", text: "Keep" } };
|
||||||
const document = { ...emptyCanvas(), nodes: [old] };
|
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", () => {
|
test("connection endpoints stay on the exact border centers after resize and rotation", () => {
|
||||||
const node = new TextCardElement({ x: 20, y: 30 }).toNode();
|
const node = new TextCardElement({ x: 20, y: 30 }).toNode();
|
||||||
const expected = {
|
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 });
|
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", () => {
|
test("Bezier paths include both exact card anchors, independent of selected handle sizes", () => {
|
||||||
const source = new TextCardElement({ x: 20, y: 30 }).toNode();
|
const source = new TextCardElement({ x: 20, y: 30 }).toNode();
|
||||||
const target = new TextCardElement({ x: 500, y: 180 }).toNode();
|
const target = new TextCardElement({ x: 500, y: 180 }).toNode();
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 本地图标目录/分片和共享 ResourceLoader 测试。直接读仓库生成资产,校验本地哈希和引用,不访问图标 CDN。
|
||||||
|
* loader 的活跃并发与缓存容量受限,不能据此推断待处理队列本身有长度上限。
|
||||||
|
*/
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
@@ -8,10 +12,12 @@ import { iconAsset, validateIconShard } from "../src/lib/canvas-icon-loader.ts";
|
|||||||
import { decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
import { decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
||||||
import { createResourceLoader } from "../src/lib/resource-loader.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 () => {
|
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 === "lucide").length, 2313);
|
||||||
assert.equal(catalog.filter((icon) => icon.source === "tabler").length, 24);
|
assert.equal(catalog.filter((icon) => icon.source === "tabler").length, 24);
|
||||||
assert.equal(new Set(catalog.map((icon) => icon.name)).size, catalog.length);
|
assert.equal(new Set(catalog.map((icon) => icon.name)).size, catalog.length);
|
||||||
|
// 按 manifest 逐分片读取本地 JSON;文件名哈希取 trimEnd 后文本的 SHA-256 前 12 位,单片小于 20000 字节。
|
||||||
const loaded = new Map();
|
const loaded = new Map();
|
||||||
for (const asset of Object.values(manifest.assets)) {
|
for (const asset of Object.values(manifest.assets)) {
|
||||||
const content = await readFile(`public${manifest.root}/${asset}`, "utf8");
|
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", () => {
|
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" }]]) {
|
for (const node of [["script", {}], ["path", { onload: "alert(1)" }], ["path", { fill: "url(https://example.com)" }], ["use", { href: "bad" }]]) {
|
||||||
assert.throws(() => validateIconShard({ Star: [node] }));
|
assert.throws(() => validateIconShard({ Star: [node] }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 让 setImmediate 推进到调度任务阶段;用于观察尚未开始、排队及已释放容量的加载。
|
||||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
// request 后立即 abort,尚未进入调度时不能启动网络加载,也不能把未完成值留入缓存。
|
||||||
test("an immediately unmounted icon does not start a network request", async () => {
|
test("an immediately unmounted icon does not start a network request", async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const loader = createResourceLoader(async () => { calls++; return "unused"; });
|
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);
|
assert.equal(calls, 0); assert.equal(loader.peek("unused"), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同 key a 两个订阅只加载一次,容量二时 c 等待;完成后缓存命中不新增调用,峰值为二。
|
||||||
test("loader bounds concurrency, deduplicates shared shards and reuses cache", async () => {
|
test("loader bounds concurrency, deduplicates shared shards and reuses cache", async () => {
|
||||||
let active = 0, peak = 0, calls = 0;
|
let active = 0, peak = 0, calls = 0;
|
||||||
|
// 手动完成 active 加载,用于在容量为二时观察第三项等待及共享 key 去重。
|
||||||
const finish = [];
|
const finish = [];
|
||||||
const loader = createResourceLoader(async (key) => {
|
const loader = createResourceLoader(async (key) => {
|
||||||
active++; peak = Math.max(peak, active); calls++;
|
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);
|
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 () => {
|
test("unused queued loads never start; a remaining subscriber keeps a shared load alive", async () => {
|
||||||
const seen = []; let release;
|
const seen = []; let release;
|
||||||
const loader = createResourceLoader(async (key) => { seen.push(key); if (key === "a") await new Promise((resolve) => release = resolve); return key; }, 1);
|
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"]);
|
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 () => {
|
test("active aborts and failed requests can retry; least recently used cache is bounded", async () => {
|
||||||
let fail = true;
|
let fail = true;
|
||||||
const loader = createResourceLoader(async (key, signal) => {
|
const loader = createResourceLoader(async (key, signal) => {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 旧画布 CanvasElement 类体系的纯图片能力及图标搜索测试,与 headless component registry 的模型定义分开。
|
||||||
|
* 只操作节点数据、几何和序列化,不挂载 renderer 或读取图片字节。
|
||||||
|
*/
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
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 { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
|
||||||
import { iconCatalog, iconsById, searchIcons } from "../src/components/canvas/icon-catalog.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", () => {
|
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==");
|
const image = new PlainImageElement({ x: 0, y: 0 }, "", "data:image/png;base64,YQ==");
|
||||||
assert(image instanceof CanvasElement); assert.equal(image instanceof CardElement, false);
|
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, "图片");
|
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", () => {
|
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 image = makeCanvasNode("plain-image", { x: 0, y: 0 }, "data:image/png;base64,YQ==");
|
||||||
const card = makeCanvasNode("text", { x: 300, y: 0 });
|
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)));
|
assert.throws(() => decodeCanvas(JSON.stringify(document)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 图标 ID 唯一并符合后端字符形状,中文类别与英文词可搜索;仅验证当前本地目录,不请求后端搜索。
|
||||||
test("the complete Lucide catalog has unique backend-compatible identifiers and searchable names", () => {
|
test("the complete Lucide catalog has unique backend-compatible identifiers and searchable names", () => {
|
||||||
assert(iconCatalog.length > 2000);
|
assert(iconCatalog.length > 2000);
|
||||||
assert.equal(iconsById.size, iconCatalog.length);
|
assert.equal(iconsById.size, iconCatalog.length);
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 旧 linked-board 成员迁移的 hook 与 DOM 场景,由 canvas-ui-check 提供运行环境。
|
||||||
|
* 内存 records/receipt 模拟版本、丢响应和能力探测;它验证客户端协调,不能替代后端数据库原子性或持久恢复测试。
|
||||||
|
*/
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
// 先直接驱动 useCanvasTree 精确控制竞态,再以 GraphCanvas 的拖放事件检查局部状态和其他节点可编辑。
|
||||||
export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasNode,emptyCanvas,tick,mock,GraphCanvas,event,window}) {
|
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 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 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 icon=makeCanvasNode('icon',{x:10,y:20});icon.id='moving';
|
||||||
const other=makeCanvasNode('text',{x:950,y:100});other.id='other';
|
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;
|
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'}});
|
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;}
|
function Fixture(){const seed=structuredClone(records.get(parent));tree=useCanvasTree(parent,'w',seed.document,seed);return null;}
|
||||||
|
// 卸载后重建父子快照与请求记录;模式选择控制能力慢响应、失败、版本冲突、丢 ACK 或挂起提交。
|
||||||
const reset=async(nextMode='success')=>{
|
const reset=async(nextMode='success')=>{
|
||||||
await act(async()=>{root.render(null);await tick();});localStorage.clear();
|
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)]}}]]);
|
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;
|
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';}
|
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={})=>{
|
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';
|
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==='ai/runs')return json({runs:[]});
|
||||||
|
// 慢响应延迟超过两秒;一次网络失败只影响首个请求,允许检查重新探测而非永久判为不支持。
|
||||||
if(suffix==='capabilities'){
|
if(suffix==='capabilities'){
|
||||||
capCalls++;if(capMode==='slowcap'){await new Promise(r=>setTimeout(r,2100));if(options.signal?.aborted)throw new DOMException('Aborted','AbortError');}
|
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');
|
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});
|
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==='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'){
|
if(suffix==='subgraphs'&&method==='POST'){
|
||||||
const input=JSON.parse(options.body).frame,next=structuredClone(records.get(parent)),childID='66666666-6666-4666-8666-666666666666';
|
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);
|
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;});
|
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}});
|
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']});
|
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);}
|
if(receipt && options.headers['Idempotency-Key']===key){assert.equal(options.body,body);return json(receipt);}
|
||||||
receipt=null;
|
receipt=null;
|
||||||
body=options.body;key=options.headers['Idempotency-Key'];const input=JSON.parse(body);
|
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);
|
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==='conflict'){records.get(parent).version++;mode='success';return json({error:'version_conflict'},412);}
|
||||||
if(mode==='rejected'){mode='success';return json({error:'ownership_conflict'},409);}
|
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 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;
|
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};
|
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);
|
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)}))};
|
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==='lost')return json({error:'upstream_unavailable'},502);
|
||||||
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
|
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
|
||||||
return json(receipt);
|
return json(receipt);
|
||||||
});
|
});
|
||||||
await act(async()=>{root.render(h(Fixture));await tick();await tick();});
|
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);};
|
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()=>{
|
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 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');
|
await wait(()=>posts.length,'move admission');
|
||||||
};
|
};
|
||||||
|
// 迁移携带落点,不先 PATCH;挂起期间只保护待移动成员,其他文本/拖动仍可继续。
|
||||||
await reset('delayed');await start();
|
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.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});
|
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,'同步期间编辑');
|
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();});
|
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');
|
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');
|
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.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');
|
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();});
|
await act(async()=>{await tree.redo();await tick();});
|
||||||
assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130});
|
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');
|
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');
|
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();});
|
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,'响应丢失后的编辑');
|
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');
|
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 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);
|
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');
|
console.log('PASS member conflict: definite zero-write rejection reloads versions before a fresh command');
|
||||||
|
// 两个迁移共享参与者,第二个等待首个 ACK 后的新版本;不额外发送坐标 PATCH。
|
||||||
await reset('queued');await start();
|
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();});
|
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');
|
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);
|
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');
|
console.log('PASS member queue: unrelated cross-frame drops serialize versions without extra coordinate PATCH');
|
||||||
await reset();
|
await reset();
|
||||||
|
// 未上传图片尚无服务端节点:本地切换归属可撤销;上传到当前 owner 后,后续 undo/redo 才使用服务端迁移。
|
||||||
const pendingImage=makeCanvasNode('image',{x:200,y:200},'blob:editable-upload');pendingImage.id='pending-image';pendingImage.pendingUpload=pendingImage.id;
|
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();});
|
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});
|
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.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');
|
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');
|
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');
|
await reset('create-delayed');
|
||||||
const asyncImage=makeCanvasNode('image',{x:800,y:500},'blob:async-upload');asyncImage.id='async-image';asyncImage.pendingUpload=asyncImage.id;
|
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();});
|
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();});
|
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');
|
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');
|
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('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');
|
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');
|
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);
|
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');
|
console.log('PASS member compatibility: capability-gated positions and legacy two-request fallback');
|
||||||
|
// 切到真实 GraphCanvas DOM 事件:拖出子图显示局部同步徽标,另一个节点仍可拖动且 ACK 不复位它。
|
||||||
capability=true;await reset('delayed');
|
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();});
|
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"]`);
|
const member=()=>document.querySelector(`.react-flow__node[data-id="${child}::moving"]`);
|
||||||
await wait(()=>member(),'expanded icon');
|
await wait(()=>member(),'expanded icon');
|
||||||
await event(member(),'mousedown',{clientX:150,clientY:160,button:0,buttons:1});
|
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.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([...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');
|
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 otherNode=()=>document.querySelector('.react-flow__node[data-id="other"]');assert.equal(otherNode().classList.contains('draggable'),true);
|
||||||
const originalTransform=otherNode().style.transform;
|
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});
|
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.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');
|
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');
|
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();});
|
await act(async()=>{root.render(null);await tick();});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 响应头、body 总传输与空闲三类截止时间的组合测试。用 Response/ReadableStream 和 fetch 替身验证读取、取消与诊断。
|
||||||
|
* HTTP 状态 200 不代表 body 可用:传输中断、非法 JSON、后端错误和 204 在 api 层需要分别表达。
|
||||||
|
*/
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { fetchCanvasResponse } from '../src/lib/canvas-response.ts';
|
import { fetchCanvasResponse } from '../src/lib/canvas-response.ts';
|
||||||
import { api, ApiError } from '../src/lib/api.ts';
|
import { api, ApiError } from '../src/lib/api.ts';
|
||||||
|
// 延迟第二个真实流块,使传输超过 header 截止但仍在 body/idle 截止内;不是网络服务计时。
|
||||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
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()=>{
|
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 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"'}});
|
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(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);
|
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()=>{
|
test('snapshot idle timeout aborts unfinished bodies instead of returning a valid empty snapshot',async()=>{
|
||||||
const reports=[];let cancelled=false;
|
const reports=[];let cancelled=false;
|
||||||
const upstream=new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));},cancel(){cancelled=true;}}));
|
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));
|
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');
|
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()=>{
|
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'});
|
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()=>{
|
test('disconnect and consumer cancellation release the upstream read',async()=>{
|
||||||
const abort=new AbortController();let cancelled=0;
|
const abort=new AbortController();let cancelled=0;
|
||||||
const make=()=>new Response(new ReadableStream({cancel(){cancelled++;}}));
|
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'});
|
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);
|
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()=>{
|
test('client distinguishes a failed 200 body transfer, malformed JSON, server errors and 204',async()=>{
|
||||||
const original=globalThis.fetch;
|
const original=globalThis.fetch;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 旧快照画布的分支创建、文字样式、图标变换与恢复校验。这里只验证 legacy reducer/schema,不代表 headless 增量事务实现。
|
||||||
|
*/
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
||||||
import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts";
|
import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts";
|
||||||
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
|
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
|
||||||
|
|
||||||
|
// 每次返回一个新文本节点,避免样式/分支变更在不同测试之间共享可变实例。
|
||||||
function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; }
|
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", () => {
|
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"]) {
|
for (const kind of ["text", "script", "image"]) {
|
||||||
const original = seeded();
|
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", () => {
|
test("a connection released on the canvas creates the chosen node at the release point", () => {
|
||||||
const original = seeded();
|
const original = seeded();
|
||||||
const at = { x: -300, y: 700 };
|
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);
|
assert.equal(result.edges.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 颜色/背景/字体/行高等样式与字号可保存和遍历历史;部分样式清除只改目标键,保留未涉及颜色。
|
||||||
test("all text styles and font size survive serialization, undo and redo", () => {
|
test("all text styles and font size survive serialization, undo and redo", () => {
|
||||||
const original = seeded();
|
const original = seeded();
|
||||||
const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 };
|
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);
|
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", () => {
|
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" }, []]) {
|
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;
|
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] })), /图标/);
|
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", () => {
|
test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => {
|
||||||
const element = new IconElement({ x: 10, y: 20 });
|
const element = new IconElement({ x: 10, y: 20 });
|
||||||
assert(element instanceof CanvasElement);
|
assert(element instanceof CanvasElement);
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 旧 CanvasSync 完整快照自动保存及旧加载/草稿恢复测试。正在发送的请求串行,排队文档只保留最新份。
|
||||||
|
* 显式 retry 可以发送最新文档,与 headless 冻结请求的未知结果查询/原字节重试是不同契约,不可相互替代。
|
||||||
|
*/
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
|
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
|
||||||
import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts";
|
import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts";
|
||||||
import { loadBoardCanvas } from "../src/lib/canvas-loader.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; }
|
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 }; }
|
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));
|
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 () => {
|
test("serialized autosaves use successive versions and coalesce queued documents", async () => {
|
||||||
const calls = [], first = deferred();
|
const calls = [], first = deferred();
|
||||||
const writer = new CanvasSync(async (path, options) => {
|
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);
|
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 () => {
|
test("version conflicts stop automatic writes and preserve the latest pending content", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const writer = new CanvasSync(async (_path, options) => { calls.push(options); throw new Error("version_conflict"); }, "canvas", { version: 7, document: emptyCanvas() }, () => {});
|
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);
|
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 () => {
|
test("explicit retry sends the newest document after a failed write", async () => {
|
||||||
const calls = [];
|
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() }, () => {});
|
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, "");
|
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 () => {
|
test("selection and measured dimensions do not trigger redundant writes or leak UI fields", async () => {
|
||||||
const initial = document("keep"); let calls = 0;
|
const initial = document("keep"); let calls = 0;
|
||||||
const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: initial }, () => {});
|
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);
|
assert.equal(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// workspace/board 的 ID 与 slug 四组合都得到 canonical board 身份及保存快照,路径仍按传入引用构造。
|
||||||
test("all ID/slug combinations load the same canonical board and snapshot", async () => {
|
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"]) {
|
for (const workspace of ["space-id", "creative-space"]) for (const board of ["board-id", "my-board"]) {
|
||||||
const calls = [];
|
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 () => {
|
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) => { 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/);
|
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", () => {
|
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 remote = { version: 3, document: document("server") };
|
||||||
const local = document("local");
|
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);
|
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 () => {
|
test("canonical fingerprints ignore server JSON key ordering", async () => {
|
||||||
const remote = document("same"), local = document("same");
|
const remote = document("same"), local = document("same");
|
||||||
remote.nodes[0].data.textStyle = { bold: true, color: "#123456" };
|
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 () => {
|
test("board metadata and snapshot begin concurrently without a request waterfall", async () => {
|
||||||
const started = []; const releases = [];
|
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");
|
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 () => {
|
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 doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
|
||||||
const state={past:[],present:doc,future:[]};
|
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()=>{
|
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 pending={...makeCanvasNode("image",{x:30,y:40},"blob:local-file"),pendingUpload:"upload-one"};pending.id="image-local";
|
||||||
const initial=emptyCanvas(),calls=[];
|
const initial=emptyCanvas(),calls=[];
|
||||||
|
|||||||
@@ -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.
|
// Isolated DOM regression check; no browser or dev server is used.
|
||||||
// npm install --prefix /tmp/canvas-ui-tools --ignore-scripts esbuild jsdom
|
// npm install --prefix /tmp/canvas-ui-tools --ignore-scripts esbuild jsdom
|
||||||
// CANVAS_UI_TOOLS=/tmp/canvas-ui-tools node tests/canvas-ui-check.mjs
|
// 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 { tmpdir } from "node:os";
|
||||||
import { resolve } from "node:path";
|
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 tools = createRequire(resolve(process.env.CANVAS_UI_TOOLS || "/tmp/canvas-ui-tools", "package.json"));
|
||||||
const { JSDOM } = tools("jsdom");
|
const { JSDOM } = tools("jsdom");
|
||||||
const { build, stop } = tools("esbuild");
|
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 dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', { url: "http://localhost", pretendToBeVisual: true });
|
||||||
const { window } = dom;
|
const { window } = dom;
|
||||||
// React's async act creates MessageChannels; track and close test-owned ports.
|
// React's async act creates MessageChannels; track and close test-owned ports.
|
||||||
const NativeMessageChannel = globalThis.MessageChannel;
|
const NativeMessageChannel = globalThis.MessageChannel;
|
||||||
const channels = [];
|
const channels = [];
|
||||||
globalThis.MessageChannel = class extends NativeMessageChannel { constructor() { super(); channels.push(this); } };
|
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 });
|
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 });
|
Object.defineProperty(globalThis, "navigator", { value: window.navigator, configurable: true });
|
||||||
globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window);
|
globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window);
|
||||||
globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window);
|
globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window);
|
||||||
|
// 媒体查询、矩阵和 React act 环境使用最小替身,仅满足组件读取的字段。
|
||||||
window.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} });
|
window.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} });
|
||||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
globalThis.DOMMatrixReadOnly = window.DOMMatrixReadOnly = class { m22 = 1; };
|
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"; } }
|
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;
|
globalThis.PointerEvent = window.PointerEvent = PointerEvent;
|
||||||
|
// WeakMap 记录元素持有的 pointerId,仅模拟捕获 API 状态,不会自动重定向后续事件。
|
||||||
const captures = new WeakMap();
|
const captures = new WeakMap();
|
||||||
window.HTMLElement.prototype.setPointerCapture = function (id) { captures.set(this, id); };
|
window.HTMLElement.prototype.setPointerCapture = function (id) { captures.set(this, id); };
|
||||||
window.HTMLElement.prototype.hasPointerCapture = function (id) { return captures.get(this) === id; };
|
window.HTMLElement.prototype.hasPointerCapture = function (id) { return captures.get(this) === id; };
|
||||||
window.HTMLElement.prototype.releasePointerCapture = function () { captures.delete(this); };
|
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 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);
|
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); } });
|
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) }; };
|
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 {
|
globalThis.ResizeObserver = window.ResizeObserver = class {
|
||||||
constructor(callback) { this.callback = callback; }
|
constructor(callback) { this.callback = callback; }
|
||||||
observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); }
|
observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); }
|
||||||
unobserve() {} disconnect() {}
|
unobserve() {} disconnect() {}
|
||||||
};
|
};
|
||||||
|
// 场景可替换 Foundry 请求处理器;图标请求记录后从本地 public 读 JSON,拒绝其他网络目标。
|
||||||
let apiMock = null;
|
let apiMock = null;
|
||||||
const requests = [];
|
const requests = [];
|
||||||
globalThis.fetch = async (url, options) => {
|
globalThis.fetch = async (url, options) => {
|
||||||
@@ -47,26 +61,33 @@ globalThis.fetch = async (url, options) => {
|
|||||||
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
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" } });
|
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-"));
|
const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-"));
|
||||||
try {
|
try {
|
||||||
const outfile = resolve(folder, "fixture.cjs");
|
const outfile = resolve(folder, "fixture.cjs");
|
||||||
|
// 打包实际组件和 React 为 CJS,CSS 置空;因此本测试不能证明最终样式或 Next 路由行为。
|
||||||
await build({
|
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" },
|
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")],
|
outfile, bundle: true, platform: "node", format: "cjs", jsx: "automatic", logLevel: "silent", nodePaths: [resolve("node_modules")],
|
||||||
loader: { ".css": "empty" },
|
loader: { ".css": "empty" },
|
||||||
plugins: [{ name: "dynamic-component", setup(build) {
|
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\/link$/ }, () => ({ path: "link", namespace: "fixture" }));
|
||||||
build.onResolve({ filter: /^next\/dynamic$/ }, () => ({ path: "dynamic", 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() }));
|
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" }));
|
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);
|
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 root = createRoot(document.querySelector("#app"));
|
||||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
|
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(); }); };
|
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") {
|
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 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 });
|
const renderDirectory = (active, query = "") => h(BoardDirectory, { boards, workspace: "space", active, query, demo: false });
|
||||||
await act(async () => { root.render(renderDirectory("root-slug")); await tick(); });
|
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");
|
console.log("PASS directory: nested children, collapse, active ancestor expansion and search paths");
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
// 记录长定时任务并手动触发,检查空闲轮询退避和可见性暂停;finally 清理所有捕获计时器。
|
||||||
const originalSetTimeout = globalThis.setTimeout, originalClearTimeout = globalThis.clearTimeout;
|
const originalSetTimeout = globalThis.setTimeout, originalClearTimeout = globalThis.clearTimeout;
|
||||||
const scheduled = [];
|
const scheduled = [];
|
||||||
globalThis.setTimeout = (callback, delay, ...args) => { const handle = originalSetTimeout(callback, delay, ...args); if (delay >= 1000) scheduled.push({ callback, delay, handle }); return handle; };
|
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;
|
let calls = 0, controls, renders = 0;
|
||||||
apiMock = async () => { calls++; return Response.json({ runs: [] }); };
|
apiMock = async () => { calls++; return Response.json({ runs: [] }); };
|
||||||
const tree = { busy: false, writers: { current: new Map() } };
|
const tree = { busy: false, writers: { current: new Map() } };
|
||||||
|
// 挂载 AI hook 并计数渲染,检查空结果轮询既不重复请求也不造成额外渲染。
|
||||||
function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; }
|
function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; }
|
||||||
try {
|
try {
|
||||||
await act(async () => { root.render(h(Probe)); await tick(); });
|
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");
|
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; }
|
} 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 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";
|
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" }] }));
|
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 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");
|
await event(document.querySelector('[data-id="text-one"]'), "click");
|
||||||
|
// 每次重查旋转按钮,避免 portal 重渲染后持有旧引用。
|
||||||
const toggle = () => document.querySelector('[aria-label="旋转模式"]');
|
const toggle = () => document.querySelector('[aria-label="旋转模式"]');
|
||||||
await event(toggle(), "click");
|
await event(toggle(), "click");
|
||||||
|
// baseline 预期复现即时取消;正常路径要求旋转保持并可 Escape、undo/redo。
|
||||||
if (process.env.ROTATION_BASELINE === "1") {
|
if (process.env.ROTATION_BASELINE === "1") {
|
||||||
assert.equal(toggle().getAttribute("aria-pressed"), "false", "Baseline should reproduce immediate cancellation");
|
assert.equal(toggle().getAttribute("aria-pressed"), "false", "Baseline should reproduce immediate cancellation");
|
||||||
console.log("PASS baseline: portal click cancels rotation before the fix");
|
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");
|
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");
|
console.log("PASS rotation: portal click, pointer rotation, Escape, undo and redo");
|
||||||
|
|
||||||
|
// 分区进出通过 mouse 拖动触发,分别检查松开前预览、归属徽标和状态提示。
|
||||||
const textNode = document.querySelector('.react-flow__node[data-id="text-one"]');
|
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(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 });
|
||||||
await event(window, "mousemove", { clientX: 210, clientY: 200, 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」"));
|
assert.ok(document.querySelector('[role="status"]').textContent.includes("已移出「分区 A」"));
|
||||||
console.log("PASS frames: actual drag previews, membership badge, count and join/leave announcements");
|
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"]');
|
const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]');
|
||||||
await event(frameElement, "click");
|
await event(frameElement, "click");
|
||||||
const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]');
|
const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]');
|
||||||
@@ -170,6 +198,7 @@ try {
|
|||||||
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 0);
|
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");
|
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");
|
await event(document.querySelector('.react-flow__edge[data-id="edge-test"]'), "click");
|
||||||
assert.ok(document.querySelector('[aria-label="连线编辑"]'));
|
assert.ok(document.querySelector('[aria-label="连线编辑"]'));
|
||||||
await event(document.querySelector('[aria-label="添加曲线点"]'), "click");
|
await event(document.querySelector('[aria-label="添加曲线点"]'), "click");
|
||||||
@@ -186,6 +215,7 @@ try {
|
|||||||
assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null);
|
assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null);
|
||||||
console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation");
|
console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation");
|
||||||
|
|
||||||
|
// IconPicker 以确定容器高度测试窗口化 DOM、按需分片、共享 Tabler 缓存和分类后滚动复位。
|
||||||
let choice;
|
let choice;
|
||||||
await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); });
|
await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); });
|
||||||
await act(async () => { 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");
|
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");
|
console.log("PASS picker: bounded DOM, on-demand data, shared Tabler load and selection");
|
||||||
}
|
}
|
||||||
|
// 正常模式追加 V2 linked-board 场景;baseline 跳过它,但成员和上传场景仍会执行。
|
||||||
if (process.env.ROTATION_BASELINE !== "1") {
|
if (process.env.ROTATION_BASELINE !== "1") {
|
||||||
let scenario;
|
let scenario;
|
||||||
await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); });
|
await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); });
|
||||||
const { canvasV2Scenario } = scenario;
|
const { canvasV2Scenario } = scenario;
|
||||||
await canvasV2Scenario({ root, act, h, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
|
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");
|
const { canvasMemberScenario } = await import("./canvas-member-scenario.mjs");
|
||||||
await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } });
|
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");
|
const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs");
|
||||||
await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
|
await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
|
||||||
}
|
}
|
||||||
|
// 最后验证通用通知/tooltip 与 demo 画布提示,再卸载 root。
|
||||||
const { feedbackScenario } = await import("./feedback-scenario.mjs");
|
const { feedbackScenario } = await import("./feedback-scenario.mjs");
|
||||||
await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick });
|
await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick });
|
||||||
await act(async () => { root.unmount(); await tick(); });
|
await act(async () => { root.unmount(); await tick(); });
|
||||||
|
// 清理临时打包文件、JSDOM、esbuild 及 act 创建的 MessageChannel 端口,恢复原通道构造器。
|
||||||
} finally {
|
} finally {
|
||||||
await rm(folder, { recursive: true, force: true }); window.close(); stop();
|
await rm(folder, { recursive: true, force: true }); window.close(); stop();
|
||||||
for (const channel of channels) { channel.port1.close(); channel.port2.close(); }
|
for (const channel of channels) { channel.port1.close(); channel.port2.close(); }
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 旧快照画布的 DOM 上传回归场景,由 canvas-ui-check 注入 React root、事件和 API 替身。
|
||||||
|
* 不访问真实上传服务;文件内容是字符串 test,图片尺寸/加载事件由测试提供,不能证明真实图片解码或浏览器布局。
|
||||||
|
*/
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
// 依次验证本地预览、上传期间编辑、失败重试、取消/删除、迟到响应、尺寸和卸载清理。
|
||||||
export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyCanvas,event,tick,mock}) {
|
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 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'};
|
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;
|
let snapshot,uploads,saves,release,mode;
|
||||||
|
// 把替身结果编码为 Response,走应用实际的响应处理路径。
|
||||||
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
|
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 revoked=[],originalRevoke=URL.revokeObjectURL;URL.revokeObjectURL=(url)=>{revoked.push(url);originalRevoke(url);};
|
||||||
const NativeImage=window.Image;
|
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);};
|
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'){
|
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;
|
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={})=>{
|
mock(async(url,options={})=>{
|
||||||
const parts=new URL(url,'http://localhost').pathname.split('/'),suffix=parts.slice(8).join('/'),method=options.method||'GET';
|
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'){
|
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});
|
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);
|
if(mode==='failed')return json({error:'forbidden'},403);
|
||||||
|
// 仅延迟模式保留 release,场景手动释放后才返回成功,制造本地编辑与 ACK 的竞态。
|
||||||
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
|
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
|
||||||
return json(asset,201);
|
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();});
|
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='添加图片'){
|
async function choose(label='添加图片'){
|
||||||
await event(document.querySelector(`[aria-label="${label}"]`),'click');
|
await event(document.querySelector(`[aria-label="${label}"]`),'click');
|
||||||
const input=document.querySelector('input[type="file"][accept^="image/"]');
|
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');
|
await wait(()=>uploads.length,'upload started');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// 首次上传先检查 blob 预览已出现、字节到达替身,但服务端文档和保存记录仍为空。
|
||||||
await reset();await choose();
|
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');
|
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'));
|
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;
|
const imageNodeID=local.closest('.react-flow__node').dataset.id;
|
||||||
|
// 按最初节点 ID 每次重查 DOM,避免异步重渲染后继续持有已替换元素。
|
||||||
const editable=()=>document.querySelector(`.react-flow__node[data-id="${imageNodeID}"]`);
|
const editable=()=>document.querySelector(`.react-flow__node[data-id="${imageNodeID}"]`);
|
||||||
assert.ok(editable().classList.contains('draggable'),'Uploading images are real draggable nodes');
|
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});
|
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;
|
const editedPosition=editable().style.transform;
|
||||||
await event(editable().querySelector('[title="双击编辑名称"]'),'dblclick');
|
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("]');
|
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'});
|
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;
|
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 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');
|
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');
|
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();});
|
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');
|
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);
|
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);
|
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');
|
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');
|
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';
|
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 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);
|
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');
|
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');
|
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');
|
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;
|
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 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);
|
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');
|
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);}};
|
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('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');
|
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);
|
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');
|
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();});}
|
} finally {window.Image=NativeImage;URL.revokeObjectURL=originalRevoke;await act(async()=>{root.render(null);await tick();});}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 旧 schema_version=2 linked-board 画布的 JSDOM 集成场景,不是 headless 增量协议版本号。
|
||||||
|
* 真实组件消费内存 API 替身:快照、事务回执、AI 模型/事件和图片均由此文件生成;不调用模型供应商或实际 Foundry 服务。
|
||||||
|
*/
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
// 依次检查只读加载、子图编辑/AI、折叠展开、分区 undo/redo、跨 owner 连线与图片处理。
|
||||||
export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvasNode,emptyCanvas,event,tick,mock}) {
|
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 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 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 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 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 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';
|
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]}}]]);
|
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;
|
let connectionReceipt=null,connectionKey=null,connectionBody=null,connectionRequests=0;
|
||||||
const receipts=new Map();let createCount=0;
|
const receipts=new Map();let createCount=0;
|
||||||
let assetUploads=0;
|
let assetUploads=0;
|
||||||
const saves=[];let job=null,poll=0,seq=0,imageRuns=0;const events=[];
|
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 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});
|
const emit=(type,data)=>events.push({seq:++seq,type,...data});
|
||||||
|
// 按 assets、快照、能力、模型、AI、连线和事务路径分流;未列出的请求立即失败。
|
||||||
mock(async(url,options={})=>{
|
mock(async(url,options={})=>{
|
||||||
if(new URL(url,'http://localhost').pathname.endsWith('/assets') && options.method==='POST') {
|
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);
|
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 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}`);
|
const snapshot=records.get(board);assert.ok(snapshot,`Unknown canvas ${board} ${url}`);
|
||||||
if(!suffix&&method==='GET')return json(structuredClone(snapshot));
|
if(!suffix&&method==='GET')return json(structuredClone(snapshot));
|
||||||
|
// 快照保存检查 If-Match,禁止投影 ID 持久化和把子节点重复写入父画布。
|
||||||
if(!suffix&&method==='PATCH'){
|
if(!suffix&&method==='PATCH'){
|
||||||
if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412);
|
if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412);
|
||||||
const payload=JSON.parse(options.body).document;
|
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==='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/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});
|
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'){
|
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']);
|
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++;
|
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'){
|
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'});
|
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'){
|
if(suffix.startsWith('ai/runs/image-run-')&&method==='GET'){
|
||||||
poll++;if(job.status!=='cancelled')job.status=poll<2?'running':'succeeded';
|
poll++;if(job.status!=='cancelled')job.status=poll<2?'running':'succeeded';
|
||||||
if(poll===2&&job.status==='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:{}});
|
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'){
|
if(suffix==='ai/runs/test-run'&&method==='GET'){
|
||||||
poll++;
|
poll++;
|
||||||
if(poll===1){job.status='running';emit('step.running',{board_id:child,node_id:'child-text'});}
|
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'});}
|
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:{}});
|
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'){
|
if(suffix==='connections'&&method==='POST'){
|
||||||
connectionRequests++;
|
connectionRequests++;
|
||||||
if(connectionReceipt){assert.equal(options.headers['Idempotency-Key'],connectionKey);assert.equal(options.body,connectionBody);return json(connectionReceipt,201);}
|
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()}};
|
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.
|
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'){
|
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 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++;
|
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}});
|
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'){
|
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 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++;
|
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}`);
|
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};
|
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()=>{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));});
|
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(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()=>{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();});
|
await act(async()=>{await tick();});
|
||||||
|
// 通过 child::localId 查展开投影;只读挂载不得 PATCH,子节点必须只出现一次。
|
||||||
const node=()=>document.querySelector(`.react-flow__node[data-id="${child}::child-text"]`);
|
const node=()=>document.querySelector(`.react-flow__node[data-id="${child}::child-text"]`);
|
||||||
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
|
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.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);
|
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();});
|
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('测试文本模型'));
|
assert.ok(document.querySelector('[aria-label="元素 AI 工具"]'));assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
|
||||||
await event(document.querySelector('[aria-label="继续向下传播"]'),'click');
|
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');
|
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.
|
// 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});
|
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);};
|
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');
|
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');
|
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');
|
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.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');
|
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);
|
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('.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="展开子画布"]'));
|
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');
|
await event(node(),'click');
|
||||||
const fromHandle=node().querySelector('[data-handleid="bottom"]');
|
const fromHandle=node().querySelector('[data-handleid="bottom"]');
|
||||||
const toHandle=document.querySelector('.react-flow__node[data-id="outside"] [data-handleid="left"]');
|
const toHandle=document.querySelector('.react-flow__node[data-id="outside"] [data-handleid="left"]');
|
||||||
|
// JSDOM 没有真实命中布局,临时将 elementFromPoint 指向目标 handle,驱动连线事件后恢复。
|
||||||
const originalHitTest=document.elementFromPoint;document.elementFromPoint=()=>toHandle;
|
const originalHitTest=document.elementFromPoint;document.elementFromPoint=()=>toHandle;
|
||||||
await event(fromHandle,'mousedown',{clientX:250,clientY:250,button:0,buttons:1});
|
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,'mousemove',{clientX:500,clientY:250,buttons:1});
|
||||||
await event(document,'mouseup',{clientX:500,clientY:250,button:0});
|
await event(document,'mouseup',{clientX:500,clientY:250,button:0});
|
||||||
document.elementFromPoint=originalHitTest;
|
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(()=>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 waitUntil(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='重试同步'),'uncertain transaction recovery');
|
||||||
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='重试同步'),'click');
|
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(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);
|
assert.equal(records.get(parent).document.edges.length,2);
|
||||||
console.log('PASS connections: real handle gesture, capability gate, LCA transaction and exact participant versions');
|
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 event(document.querySelector('.react-flow__node[data-id="image-target"]'),'click');
|
||||||
await act(async()=>{await tick();});
|
await act(async()=>{await tick();});
|
||||||
assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试图片模型'),'Sticky AI mode selects models for the newly selected image');
|
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');
|
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);
|
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');
|
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([...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.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');
|
assert.ok(records.get(parent).document.nodes.find(node=>node.id==='legacy-image').data.src.startsWith('data:image/'),'Existing image references are not rewritten');
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 旧 schema_version=2 linked-board 快照的兼容与投影测试。schema_version、headless 文档格式和增量协议版本不是同一编号。
|
||||||
|
* 检查展开分区、曲线/样式/执行配置、owner 投影与最低共同祖先;不把任意远程 AI 请求实际执行。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { decodeCanvas, emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts';
|
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 { mergeCanvasDocuments, projectCanvasTree, transformEdgeData } from '../src/lib/canvas-tree.ts';
|
||||||
import { resizeFromPointer } from '../src/lib/canvas-geometry.ts';
|
import { resizeFromPointer } from '../src/lib/canvas-geometry.ts';
|
||||||
import { managedImageSource } from '../src/lib/canvas-v2.ts';
|
import { managedImageSource } from '../src/lib/canvas-v2.ts';
|
||||||
|
// 固定子 board 身份,投影时与符号节点 ID 组合,避免把 renderer ID 当作持久节点身份。
|
||||||
const childId = '11111111-1111-4111-8111-111111111111';
|
const childId = '11111111-1111-4111-8111-111111111111';
|
||||||
|
// 大尺寸 frame 引用子 board 和输出 port,父文字含 AI 配置,边包含完整曲线/动画/marker 数据。
|
||||||
function fixture() {
|
function fixture() {
|
||||||
const frame = makeCanvasNode('frame', {x:100,y:100}); frame.id='frame'; frame.width=5000;
|
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'}]};
|
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}}};
|
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]}};
|
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',()=>{
|
test('V2 roundtrip preserves linked frames, all curve/animation/marker data and AI config',()=>{
|
||||||
const {root}=fixture(); const decoded=decodeCanvas(JSON.stringify(root));
|
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(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);
|
assert.deepEqual(decodeCanvas(JSON.stringify(decoded)),decoded);
|
||||||
});
|
});
|
||||||
|
// 逐项破坏平滑控制向量、动画、线宽、端口方向、普通元素尺寸或 AI 参数容器,解码均失败。
|
||||||
test('V2 rejects bad control data, wrong port directions and oversized ordinary elements',()=>{
|
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=[]]){
|
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)));
|
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',()=>{
|
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);
|
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);
|
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});
|
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',()=>{
|
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);
|
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',()=>{
|
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 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);
|
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);}
|
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',()=>{
|
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 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/);
|
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',()=>{
|
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']){
|
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;
|
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 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);
|
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',()=>{
|
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');
|
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()=>{
|
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 {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);
|
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');
|
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]));
|
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()=>{
|
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 {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);
|
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()=>{
|
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 {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};
|
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};
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 旧画布快照 reducer 的基本历史、节点/边、序列化与分支规则测试。past/present/future 是旧版完整快照结构。
|
||||||
|
* 这里的 checkpoint/replace 不能当作 headless 操作账本、条件增量请求或项目恢复协议的验证。
|
||||||
|
*/
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
||||||
|
|
||||||
|
// 构造两张不同卡片及一条边,返回空 past/future 的独立历史,用于比较编辑前后内容和 viewport。
|
||||||
function seeded() {
|
function seeded() {
|
||||||
const document = emptyCanvas();
|
const document = emptyCanvas();
|
||||||
document.nodes = [makeCanvasNode("text", { x: 20, y: 30 }), makeCanvasNode("script", { x: 400, y: 30 })];
|
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: [] };
|
return { past: [], present: document, future: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkpoint 后拖动形成一个历史起点;undo/redo 改内容位置但保留当前 viewport,视图导航不跟着旧快照回退。
|
||||||
test("node dragging updates its position; undo and redo retain the current viewport", () => {
|
test("node dragging updates its position; undo and redo retain the current viewport", () => {
|
||||||
let state = seeded();
|
let state = seeded();
|
||||||
const id = state.present.nodes[0].id;
|
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 });
|
assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 删节点连同附边一起删除,随后重复边删除通知不应再追加历史;一次 undo 恢复节点与边。
|
||||||
test("deleting a node removes attached edges and undo restores both", () => {
|
test("deleting a node removes attached edges and undo restores both", () => {
|
||||||
const initial = seeded();
|
const initial = seeded();
|
||||||
const removed = canvasReducer(initial, { type: "nodes", changes: [{ id: initial.present.nodes[0].id, type: "remove" }] });
|
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);
|
assert.equal(restored.present.edges.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同一 checkpoint 内多次文字修改只留一个 past,undo 回到开始编辑前文字。
|
||||||
test("a text editing session is undoable as one operation", () => {
|
test("a text editing session is undoable as one operation", () => {
|
||||||
let state = seeded();
|
let state = seeded();
|
||||||
const id = state.present.nodes[0].id;
|
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, "");
|
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", () => {
|
test("export/import retains nodes, edges, text, images and the viewport", () => {
|
||||||
const document = seeded().present;
|
const document = seeded().present;
|
||||||
const image = makeCanvasNode("image", { x: 80, y: 90 }, "data:image/png;base64,YQ==");
|
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);
|
assert.deepEqual(restored.viewport, document.viewport);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 悬空端点、重复 ID、不安全图片 URL 和非法 zoom 分别构造非法输入,任一项都使整个 decode 失败。
|
||||||
test("import rejects dangling connections, duplicate nodes, unsafe images and invalid zoom", () => {
|
test("import rejects dangling connections, duplicate nodes, unsafe images and invalid zoom", () => {
|
||||||
for (const mutate of [
|
for (const mutate of [
|
||||||
(doc) => { doc.edges[0].target = "missing"; },
|
(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", () => {
|
test("draft storage is isolated by workspace and graph", () => {
|
||||||
assert.notEqual(canvasStorageKey("workspace-a", "board"), canvasStorageKey("workspace-b", "board"));
|
assert.notEqual(canvasStorageKey("workspace-a", "board"), canvasStorageKey("workspace-b", "board"));
|
||||||
assert.notEqual(canvasStorageKey("workspace", "board-a"), canvasStorageKey("workspace", "board-b"));
|
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", () => {
|
test("undo history is bounded and new edits clear the redo stack", () => {
|
||||||
let state = seeded();
|
let state = seeded();
|
||||||
for (let i = 0; i < 50; i++) state = canvasReducer(state, { type: "replace", document: { ...state.present, edges: [] } });
|
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", () => {
|
test("selected nodes can branch in all four directions with matching connection handles", () => {
|
||||||
const opposite = { top: "bottom", right: "left", bottom: "top", left: "right" };
|
const opposite = { top: "bottom", right: "left", bottom: "top", left: "right" };
|
||||||
for (const direction of Object.keys(opposite)) {
|
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", () => {
|
test("repeated additions on the same side avoid existing nodes", () => {
|
||||||
const document = emptyCanvas();
|
const document = emptyCanvas();
|
||||||
const parent = makeCanvasNode("text", { x: 0, y: 0 });
|
const parent = makeCanvasNode("text", { x: 0, y: 0 });
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 应用反馈组件的 JSDOM 场景:原生 title 回退、provider tooltip、通知更新及画布成功/错误提示。
|
||||||
|
* 只断言 DOM 属性、事件和状态,不证明真实屏幕阅读器播报或浏览器浮层排版。
|
||||||
|
*/
|
||||||
import assert from "node:assert/strict";
|
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 }) {
|
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(); });
|
await act(async () => { root.render(null); await tick(); });
|
||||||
let button, clicks = 0;
|
let button, clicks = 0;
|
||||||
|
// 最小 SVG 图标替身只满足 IconButton 接口,不依赖真实图标资源。
|
||||||
const icon = () => h("svg", { "aria-hidden": true });
|
const icon = () => h("svg", { "aria-hidden": true });
|
||||||
|
// ref 必须直接指向业务 button;点击计数用于检查 tooltip 不吞掉原操作。
|
||||||
const control = () => h(IconButton, { icon, label: "旋转工具", title: "旋转选中元素", ref: (value) => { button = value; }, onClick: () => { clicks++; } });
|
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(); });
|
await act(async () => { root.render(control()); await tick(); });
|
||||||
assert.equal(button.title, "旋转选中元素", "Standalone controls retain native explanations");
|
assert.equal(button.title, "旋转选中元素", "Standalone controls retain native explanations");
|
||||||
await act(async () => { root.render(h(AppFeedback, null, h("div", { id: "controls" }, control()))); await tick(); });
|
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.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.tagName, "BUTTON", "Forwarded ref still points at the actual button");
|
||||||
assert.equal(button.title, "", "A provider avoids duplicate native and Radix tooltips");
|
assert.equal(button.title, "", "A provider avoids duplicate native and Radix tooltips");
|
||||||
|
// 键盘聚焦应出现说明并建立 aria-describedby,Escape 隐藏说明后点击仍能触发业务回调。
|
||||||
await act(async () => { button.focus(); await tick(); });
|
await act(async () => { button.focus(); await tick(); });
|
||||||
const tooltip = document.querySelector('[role="tooltip"]');
|
const tooltip = document.querySelector('[role="tooltip"]');
|
||||||
assert.ok(tooltip?.textContent.includes("旋转选中元素"), "Keyboard focus exposes the explanation");
|
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" });
|
await event(document, "keydown", { key: "Escape" });
|
||||||
assert.ok(!document.querySelector('[role="tooltip"]'), "Escape dismisses the tooltip");
|
assert.ok(!document.querySelector('[role="tooltip"]'), "Escape dismisses the tooltip");
|
||||||
await event(button, "click"); assert.equal(clicks, 1, "Tooltips preserve business clicks");
|
await event(button, "click"); assert.equal(clicks, 1, "Tooltips preserve business clicks");
|
||||||
|
// 同一通知 ID 更新内容,只保留一个 toast;随后显式 dismiss 并等待退出清理。
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
notify({ id: "feedback-check", title: "正在处理" }); await tick();
|
notify({ id: "feedback-check", title: "正在处理" }); await tick();
|
||||||
notify({ id: "feedback-check", title: "已完成", level: "success" }); 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");
|
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");
|
console.log("PASS feedback: native fallback, forwarded ref, keyboard tooltip/Escape, clicks and stable notification IDs");
|
||||||
|
|
||||||
|
// 播种旧版 demo 本地草稿,包含文本与分区;这不是服务端成员迁移测试。
|
||||||
const id = "feedback-board";
|
const id = "feedback-board";
|
||||||
const node = { ...makeCanvasNode("text", { x: 100, y: 100 }), id: "feedback-text" };
|
const node = { ...makeCanvasNode("text", { x: 100, y: 100 }), id: "feedback-text" };
|
||||||
const frame = { ...makeCanvasNode("frame", { x: 500, y: 100 }), id: "feedback-frame" };
|
const frame = { ...makeCanvasNode("frame", { x: 500, y: 100 }), id: "feedback-frame" };
|
||||||
frame.data.title = "提示分区";
|
frame.data.title = "提示分区";
|
||||||
localStorage.setItem(`endoleap_canvas_v1:w:${id}`, JSON.stringify({ ...emptyCanvas(), nodes: [node, frame] }));
|
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(); });
|
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"]');
|
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(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 });
|
||||||
await event(window, "mousemove", { clientX: 210, clientY: 200, 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 });
|
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('[data-sonner-toast][data-type="success"]')?.textContent.includes("已加入「提示分区」"));
|
||||||
assert.ok(document.querySelector('[title="属于:提示分区"]'), "Notifications do not replace actual membership state");
|
assert.ok(document.querySelector('[title="属于:提示分区"]'), "Notifications do not replace actual membership state");
|
||||||
|
// 用 PDF MIME 模拟不支持的图片选择,错误通知出现后继续创建文本,检查错误不阻塞编辑。
|
||||||
const fileInput = document.querySelector('input[type="file"][accept^="image/"]');
|
const fileInput = document.querySelector('input[type="file"][accept^="image/"]');
|
||||||
Object.defineProperty(fileInput, "files", { configurable: true, value: [new File(["test"], "test.pdf", { type: "application/pdf" })] });
|
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(); });
|
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("演示模式请选择"));
|
assert.ok(document.querySelector('[data-sonner-toast][data-type="error"]')?.textContent.includes("演示模式请选择"));
|
||||||
await event(document.querySelector('[aria-label="添加文本卡片"]'), "click");
|
await event(document.querySelector('[aria-label="添加文本卡片"]'), "click");
|
||||||
assert.equal(document.querySelectorAll('.react-flow__node').length, 3, "An error notification never blocks editing");
|
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(); });
|
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");
|
console.log("PASS canvas feedback: membership success, input errors and continued local editing through the application provider");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
||||||
import {sealRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
|
import {sealRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
|
||||||
|
// 手工建立旧数据库后再通过生产 storage 升级,直接观察持久化行及重新打开后的恢复结果。
|
||||||
export async function blobCheckpointCase(assert){
|
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"}'}}});
|
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('');
|
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);});
|
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;
|
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{
|
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'])}]});
|
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');
|
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');
|
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;};
|
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']]){
|
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);}
|
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);
|
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;
|
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;};
|
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;}
|
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');
|
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');
|
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');
|
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);}
|
}finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保持旧 DB 连接打开以阻挡升级;同一失败 adapter 保留原错误,旧连接退场后需新 adapter 才能成功。
|
||||||
export async function blockedUpgradeCase(assert){
|
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;
|
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');}
|
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);}
|
finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 浏览器摘要适配器的协议故障测试:使用可控 Worker 替身检查请求/响应身份,以及启动和运行失败的错误区分。
|
||||||
|
* 不启动真实线程,也不验证浏览器打包;每个用例恢复 globalThis.Worker,避免污染其他测试。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createBrowserDigests} from '../../src/lib/canvas-runtime/browser-digests.ts';
|
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()=>{
|
test('browser digest adapter rejects mismatched worker identity and terminates the worker',async()=>{
|
||||||
const previous=globalThis.Worker;let terminated=0;
|
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;}
|
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()=>{
|
test('browser digest adapter exposes worker startup and runtime failures without returning a false hash',async()=>{
|
||||||
const previous=globalThis.Worker;let pool;
|
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;}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 浏览器迁移控制器与旧画布编辑租约测试。旧编辑视图持 shared lease,迁移要求独占且不等待的 lease,避免新旧写入并行。
|
||||||
|
* 使用内存 localStorage/Web Locks 替身验证协调逻辑,不代表真实多标签 Web Locks 已在本文件执行。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {claimLegacyCanvasEditing,createBrowserCanvasMigration} from '../../src/lib/browser-canvas-migration.ts';
|
import {claimLegacyCanvasEditing,createBrowserCanvasMigration} from '../../src/lib/browser-canvas-migration.ts';
|
||||||
import {canvasMigrationStorageKey} from '../../src/lib/canvas-migration.ts';
|
import {canvasMigrationStorageKey} from '../../src/lib/canvas-migration.ts';
|
||||||
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.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());
|
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(){
|
function fixture(){
|
||||||
const records=new Map(),held=new Map(),calls=[];
|
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)};
|
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()=>{
|
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;
|
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(!granted)return work(null);
|
||||||
if(options.mode==='shared')state.shared++;else state.exclusive=true;held.set(name,state);
|
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);}
|
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};
|
const controller=new AbortController(),options={scope,registry,locks,storage,signal:controller.signal,isCurrentIdentity:()=>true};
|
||||||
return{records,held,calls,controller,options};
|
return{records,held,calls,controller,options};
|
||||||
}
|
}
|
||||||
|
// 两个旧编辑器可共享锁,释放一个仍不能迁移;全部释放后 exclusive 迁移检查可进入 idle,所有请求均 ifAvailable。
|
||||||
test('editable legacy views share a lease and exclude migration until every editor releases',async()=>{
|
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 f=fixture(),one=await claimLegacyCanvasEditing({...f.options,canvasId:root}),two=await claimLegacyCanvasEditing({...f.options,canvasId:root});
|
||||||
const migration=createBrowserCanvasMigration(f.options);
|
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.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'));
|
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()=>{
|
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}})};
|
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));
|
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);
|
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);
|
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()=>{
|
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 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);
|
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
@@ -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 { createServer } from 'node:http';
|
||||||
import { readFile, mkdtemp, rm } from 'node:fs/promises';
|
import { readFile, mkdtemp, rm } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
@@ -7,18 +11,24 @@ import { spawn } from 'node:child_process';
|
|||||||
import { stripTypeScriptTypes } from 'node:module';
|
import { stripTypeScriptTypes } from 'node:module';
|
||||||
import { setTimeout as pause } from 'node:timers/promises';
|
import { setTimeout as pause } from 'node:timers/promises';
|
||||||
|
|
||||||
|
// 从脚本位置定位仓库,随机临时 profile 保证测试不读写日常 Chrome 会话。
|
||||||
const root = resolve(import.meta.dirname, '../..'), profile = await mkdtemp(join(tmpdir(), 'endoleap-storage-browser-'));
|
const root = resolve(import.meta.dirname, '../..'), profile = await mkdtemp(join(tmpdir(), 'endoleap-storage-browser-'));
|
||||||
|
// --package 只影响发布文件服务和页面导入入口;它不会在这里自动构建 .headless-dist。
|
||||||
const packageMode=process.argv.includes('--package');
|
const packageMode=process.argv.includes('--package');
|
||||||
|
// 按 key 保留挂起的响应与读取次数,供页面测试控制共享读取的首块、尾块和失败尾部。
|
||||||
const readStreams=new Map();
|
const readStreams=new Map();
|
||||||
|
// 只在回环地址提供 fixture;源码读取或类型去除失败时返回 500 供页面检查报告。
|
||||||
const server = createServer(async (request, response) => {
|
const server = createServer(async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const path = new URL(request.url, 'http://localhost').pathname;
|
const path = new URL(request.url, 'http://localhost').pathname;
|
||||||
|
// 同一 key 每次读取计数增加,立即发送第一行并保持连接;由独立 release 请求决定何时结束。
|
||||||
if(path==='/loader-stream'){
|
if(path==='/loader-stream'){
|
||||||
const url=new URL(request.url,'http://localhost'),key=url.searchParams.get('key');
|
const url=new URL(request.url,'http://localhost'),key=url.searchParams.get('key');
|
||||||
if(!key){response.writeHead(400).end();return;}
|
if(!key){response.writeHead(400).end();return;}
|
||||||
const state=readStreams.get(key)??{reads:0};state.reads++;state.response=response;readStreams.set(key,state);
|
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;
|
response.writeHead(200,{'Content-Type':'application/x-ndjson'});response.write(JSON.stringify({part:0,complete:false})+'\n');return;
|
||||||
}
|
}
|
||||||
|
// 查找挂起流,可发送合法完成行或非法 JSON 尾部;返回读次数用于核对共享加载没有重复 fetch。
|
||||||
if(path==='/loader-release'){
|
if(path==='/loader-release'){
|
||||||
const url=new URL(request.url,'http://localhost'),state=readStreams.get(url.searchParams.get('key'));
|
const url=new URL(request.url,'http://localhost'),state=readStreams.get(url.searchParams.get('key'));
|
||||||
if(!state){response.writeHead(404).end();return;}
|
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;
|
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; }
|
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 isPackage=packageMode&&path.startsWith('/package/');
|
||||||
const filename = isPackage?resolve(root,'.headless-dist',path.slice('/package/'.length)):resolve(root, `.${path}`);
|
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; }
|
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');
|
let source = await readFile(filename, 'utf8');
|
||||||
if (filename.endsWith('.ts')) source = stripTypeScriptTypes(source);
|
if (filename.endsWith('.ts')) source = stripTypeScriptTypes(source);
|
||||||
response.setHeader('Content-Type', 'text/javascript'); response.end(source);
|
response.setHeader('Content-Type', 'text/javascript'); response.end(source);
|
||||||
} catch (error) { response.writeHead(500).end(String(error)); }
|
} 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));
|
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' });
|
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;
|
let socket;
|
||||||
try {
|
try {
|
||||||
|
// 最多轮询 100 次 DevToolsActivePort,每次间隔 100 毫秒;未找到端口即失败,不连接用户现有浏览器。
|
||||||
let port;
|
let port;
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
try { port = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); }
|
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');
|
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());
|
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);
|
socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl);
|
||||||
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
|
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
|
||||||
|
// pending 仅按 ID 路由 Promise;这里没有每条命令的本地截止定时器。
|
||||||
let next = 0; const pending = new Map();
|
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); } };
|
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 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}`;
|
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
// 导航到本次 fixture origin,随后最多 100 次检查页面 origin 与 readyState,避免误在空白页执行。
|
||||||
await call('Page.navigate', { url: `${origin}/` });
|
await call('Page.navigate', { url: `${origin}/` });
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
@@ -58,10 +77,13 @@ try {
|
|||||||
await pause(50);
|
await pause(50);
|
||||||
}
|
}
|
||||||
if (!loaded) throw new Error('browser test origin did not finish loading');
|
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 });
|
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);
|
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
|
||||||
for (const message of result.result.value) console.log(`PASS ${message}`);
|
for (const message of result.result.value) console.log(`PASS ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
// 清理连接并请求 Chrome 退出,等待进程 exit 后再关闭 HTTP server 和删除 profile;此等待没有额外强杀截止时间。
|
||||||
socket?.close();
|
socket?.close();
|
||||||
const exited = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); });
|
const exited = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); });
|
||||||
chrome.kill(); await exited;
|
chrome.kill(); await exited;
|
||||||
|
|||||||
@@ -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}={}){
|
export function createCdpCalls(socket,{timeoutMs=30000,onEvent=()=>{},setTimer=setTimeout,clearTimer=clearTimeout}={}){
|
||||||
|
// pending 是未回应调用的唯一查找表;failure 保留首个致命错误,使未来调用立刻拒绝。
|
||||||
let serial=0,failure;const pending=new Map();
|
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();};
|
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('close',()=>fail(Error('CDP connection closed')));
|
||||||
socket.addEventListener('error',()=>fail(Error('CDP connection failed')));
|
socket.addEventListener('error',()=>fail(Error('CDP connection failed')));
|
||||||
|
// 收到消息先解析 JSON;解析或事件回调异常同样会进入全局失败。
|
||||||
socket.addEventListener('message',event=>{
|
socket.addEventListener('message',event=>{
|
||||||
try{
|
try{
|
||||||
const message=JSON.parse(event.data);
|
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;}
|
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);
|
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);}
|
}catch(error){fail(error);}
|
||||||
});
|
});
|
||||||
|
// 为新命令分配递增 ID,先登记 pending 与定时器再发送;send 同步抛错也会拒绝全部在途工作。
|
||||||
return(method,params={})=>new Promise((resolve,reject)=>{
|
return(method,params={})=>new Promise((resolve,reject)=>{
|
||||||
if(failure){reject(failure);return;}
|
if(failure){reject(failure);return;}
|
||||||
const id=++serial,row={resolve,reject,timer:undefined};pending.set(id,row);
|
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);
|
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);}
|
try{socket.send(JSON.stringify({id,method,params}));}catch(error){fail(error);}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 浏览器测试专用 CDP 调用器的单元测试。用 socket/定时器替身验证请求 ID 路由、截止时间及致命故障,不启动浏览器。
|
||||||
|
* 单个协议命令错误只拒绝对应请求;连接或目标级故障则拒绝整个调用器及未来调用。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createCdpCalls} from './cdp-client.mjs';
|
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)})};}
|
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);});
|
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/);});
|
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('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);});
|
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);});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 显式冲突恢复测试:用户选择 keep-local 或 discard-local 后,基于已审阅的远程基线重建条件操作和撤销链。
|
||||||
|
* 旧请求及拒绝证据保留在账本与恢复审计中;结果未知的操作不能靠用户选择直接退休,付费动作不能自动重启。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
|
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -5,19 +9,56 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
|||||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||||
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
||||||
|
|
||||||
|
// 共享类型定义和目标身份,保证冲突处理仍执行数据校验及 incarnation 校验。
|
||||||
const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1};
|
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 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 original=createDocument('project',[{id:'canvas',parent:null}],[element]);
|
||||||
|
// 推进有限轮微任务,供恢复读取进入挂起点;不等待被故意阻塞的完整恢复会话。
|
||||||
const tick=async()=>{for(let i=0;i<40;i++)await Promise.resolve();};
|
const tick=async()=>{for(let i=0;i<40;i++)await Promise.resolve();};
|
||||||
|
// 默认拒绝写入并允许外部修改服务基线;存储可注入 quota,支持验证恢复计划先发布后落盘失败的重试。
|
||||||
function fixture(){
|
function fixture(){
|
||||||
let server=original,version=1,image,leader;const calls=[];
|
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;
|
return f;
|
||||||
}
|
}
|
||||||
|
// 只收集尚未解决的非上传操作,生成恢复选项时不把独立资源上传当作文档编辑。
|
||||||
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
|
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
|
||||||
|
// 为每个待处理操作显式指定选择;某些用例覆盖个别选择以验证依赖闭包和动作限制。
|
||||||
const choices=(engine,choice='keep-local')=>new Map(pending(engine).map(record=>[record.intent.id,choice]));
|
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);
|
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()=>{
|
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 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));
|
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();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 丢弃中间一步后重连剩余操作依赖和 undo 链;旧历史及恢复前内容仍保存在审计备份。
|
||||||
test('discarding a middle edit preserves the remaining undo chain and archives the old history',async()=>{
|
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 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);
|
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();
|
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()=>{
|
test('typing groups preserve an acknowledged prefix unless the server changed that field',async()=>{
|
||||||
for(const remote of [false,true]){
|
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();
|
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()=>{
|
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 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);
|
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();
|
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()=>{
|
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 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});
|
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();
|
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()=>{
|
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 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'));
|
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();
|
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()=>{
|
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));
|
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);
|
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();
|
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()=>{
|
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();
|
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();
|
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',()=>{
|
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});
|
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);
|
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);
|
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()=>{
|
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 {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();
|
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();
|
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()=>{
|
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));
|
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();
|
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();
|
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()=>{
|
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));
|
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(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();
|
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()=>{
|
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 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;
|
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();
|
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()=>{
|
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 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();
|
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()=>{
|
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]){
|
for(const keep of [false,true]){
|
||||||
const f=fixture();let engine=await f.open();
|
const f=fixture();let engine=await f.open();
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 复制快照编解码的协作式预算测试。调度只影响让出时机,不能影响快照字节、墓碑、索引读取或取消语义。
|
||||||
|
* 同时覆盖普通/索引快照及旧 prepared duplicate 源格式;假时钟与工作量上限分别控制耗时和粗时钟场景。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
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 {encodeIndexedCopySnapshot,openIndexedCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot-index.ts';
|
||||||
import {encodeDuplicateSource} from '../../src/lib/canvas-runtime/import-duplicate.ts';
|
import {encodeDuplicateSource} from '../../src/lib/canvas-runtime/import-duplicate.ts';
|
||||||
import {createCopyBudget} from '../../src/lib/canvas-runtime/copy-budget.ts';
|
import {createCopyBudget} from '../../src/lib/canvas-runtime/copy-budget.ts';
|
||||||
|
// 内置定义和独立 AbortSignal 工厂,避免一个场景的取消状态传入另一个场景。
|
||||||
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
|
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}});
|
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]]));
|
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;}};}
|
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()=>{
|
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]]){
|
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]);
|
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()=>{
|
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);
|
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);}
|
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');
|
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()=>{
|
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 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');
|
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()=>{
|
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');
|
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);
|
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()=>{
|
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');
|
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 {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,defineComponent} from '../../src/lib/canvas-core/index.ts';
|
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 {encodeDuplicateSource,prepareDuplicateImport,assertDuplicateExternal} from '../../src/lib/canvas-runtime/import-duplicate.ts';
|
||||||
import {selectImportGroups} from '../../src/lib/canvas-runtime/import-groups.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}});
|
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());
|
const registry=createRegistry(builtinComponents());
|
||||||
|
|
||||||
|
// 复制 frame 的持久源须声明子节点依赖并使用新 nodeId;篡改为旧引用或删掉依赖都在导入准备时拒绝。
|
||||||
test('durable copy source includes mapped port dependencies and refuses an old source still targeting original children',async()=>{
|
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 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');
|
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/);
|
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()=>{
|
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 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'}};
|
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/);
|
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});
|
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',()=>{
|
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}),[]);
|
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']);
|
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 {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,validateDocument} from '../../src/lib/canvas-core/index.ts';
|
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 {prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||||
import {copyRegistrySignature,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
import {copyRegistrySignature,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||||
import {createDuplicatePlanSource} from '../../src/lib/canvas-runtime/duplicate-source.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 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]]));
|
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');
|
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()=>{
|
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 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}`});
|
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/);
|
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()=>{
|
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 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/);
|
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()=>{
|
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');
|
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()));}
|
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/);
|
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()=>{
|
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=[];
|
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);}}
|
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;
|
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');
|
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');
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 完整复制快照及准备期任务检查点测试。正文、canvas、墓碑和未知字段需完整往返,并与来源项目绑定。
|
||||||
|
* 准备期尚未发布内容,任务不能同时带运行游标或 committedSourceKeys;格式/解释器不匹配必须拒绝。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||||
import {createImportJob,validateImportJob,createImportRunner} from '../../src/lib/canvas-runtime/imports.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]]));
|
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;
|
const signal=()=>new AbortController().signal;
|
||||||
|
// 完整快照往返保持元素/canvas/墓碑及未知数据,输出元素数据冻结;错误项目 ID 拒绝。
|
||||||
test('copy snapshot roundtrip preserves the complete source, identity lifecycles and unknown data',async()=>{
|
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/);
|
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()=>{
|
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()));
|
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 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/);
|
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',()=>{
|
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);
|
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}));
|
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}));
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 从真实引擎持久化序列提取各个崩溃切点,再用匹配的服务端状态重新打开。
|
||||||
|
* 比较文档、history、ledger、请求次数和冻结字节;这是镜像恢复模拟,不会终止真实进程或测试真实磁盘掉电。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -5,12 +9,18 @@ import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
|||||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||||
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.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 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:''}}]);
|
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){
|
function fixture(image,initialServer=original,initialReceipt){
|
||||||
let owner,saved=image&&structuredClone(image),server=initialServer,version=initialReceipt?2:1,now=1000,next=0;
|
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 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 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=>{
|
const transport=async input=>{
|
||||||
calls.push(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)}};}
|
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 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};
|
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(){}};
|
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)}};
|
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');}};
|
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=>{
|
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 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);
|
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();
|
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');
|
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();
|
assert.ok(pending&&frozen&&dispatch&&resolved,'producer did not expose every required durable transition');await engine.close();
|
||||||
|
// 初始镜像没有尚未落盘的可见编辑;后续每个场景都明确指定应查询/写入的次数及服务端是否已执行。
|
||||||
for(const scenario of[
|
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:'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},
|
{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
|
// frozen 切点也保守查询;unknown 仅允许原请求重试,不能改用新身份或宣称服务未发生副作用。
|
||||||
// retry, not a new identity or a claim that no effect could have happened.
|
|
||||||
{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:'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:'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:'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},
|
{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()=>{
|
])await t.test(scenario.name,async()=>{
|
||||||
// The server state is matched to the cut: it is never rewound after a
|
// 本地 dispatch 切点可对应服务执行前或执行后;若服务已经提交,不能因读取旧镜像就把服务状态回退。
|
||||||
// dispatched effect merely because an older local image was selected.
|
|
||||||
const f=fixture(scenario.image,scenario.server,scenario.receipt),recovered=await f.open();
|
const f=fixture(scenario.image,scenario.server,scenario.receipt),recovered=await f.open();
|
||||||
try{
|
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);
|
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);
|
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]));
|
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);}
|
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);
|
assert.equal(recovered.getDurability().rootRevision,recovered.getDurability().durableRootRevision);assert.equal(recovered.sessions.getSnapshot().views.size,0);
|
||||||
}finally{await recovered.close();}
|
}finally{await recovered.close();}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 摘要池的容量与生命周期测试。活动执行数和 FIFO 等待数分别受限;调用者取消不等于底层执行器已经退出。
|
||||||
|
* 以固定格式摘要和手动完成的执行器隔离哈希算法,专门验证调度、关闭及输出校验。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDigestPool} from '../../src/lib/canvas-runtime/digest-pool.ts';
|
import {createDigestPool} from '../../src/lib/canvas-runtime/digest-pool.ts';
|
||||||
|
// hash 仅作合法输出样本;tick 让执行器的微任务/定时器推进,便于检查当前实际启动次序。
|
||||||
const hash='a'.repeat(64),tick=()=>new Promise(resolve=>setTimeout(resolve,0));
|
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()=>{
|
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/);
|
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()=>{
|
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();
|
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()=>{
|
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();
|
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 {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';
|
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()=>{
|
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])])]){
|
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);}};
|
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()=>{
|
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');}}
|
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/);
|
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/);
|
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()=>{
|
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;
|
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)]);}}
|
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)));
|
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);
|
const digest=await digestWorkerSource(new ChunkOnly(),{digest(){throw Error('native budget exceeded');}});assert.equal(digest,hash.digest('hex'));assert.ok(largest<=1024*1024);
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 资源显示租约缓存测试。按 owner 和资源键共享进行中的读取/URL,最后租约释放即清理,不保留空闲 URL。
|
||||||
|
* 每个等待者可独立取消;旧读取迟到结果应清理自身,不能替换同键的新代次或破坏其他 owner。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDisplayCache } from '../../src/lib/canvas-runtime/display-cache.ts';
|
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 }; };
|
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));
|
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 () => {
|
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;
|
const cache = createDisplayCache(), owner = {}; let loads = 0, disposed = 0;
|
||||||
|
// 可观测显示源工厂:每次真实读取产生新 URL,源 release 计数代表底层资源释放。
|
||||||
const load = async () => ({ url: `blob:${++loads}`, release: () => disposed++ });
|
const load = async () => ({ url: `blob:${++loads}`, release: () => disposed++ });
|
||||||
const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load);
|
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);
|
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);
|
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);
|
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 () => {
|
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;
|
const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, disposed = 0;
|
||||||
|
// 捕获共享读取的 AbortSignal 并挂起,区分释放单个租约与终止整个底层读取。
|
||||||
const load = async value => { signal = value; return gate.promise; };
|
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 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);
|
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);
|
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 () => {
|
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 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();
|
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);
|
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);
|
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 () => {
|
test('read failure retains its cause and removes the failed cache entry for a fresh retry', async () => {
|
||||||
const cache = createDisplayCache(), owner = {};
|
const cache = createDisplayCache(), owner = {};
|
||||||
const a = cache.acquire(owner, 'asset', async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); });
|
const a = cache.acquire(owner, 'asset', async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); });
|
||||||
await assert.rejects(a.ready, { 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();
|
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 () => {
|
test('project owner scopes isolate identical IDs and retired owners cannot reacquire', async () => {
|
||||||
const cache = createDisplayCache(), a = {}, b = {}; let disposed = 0;
|
const cache = createDisplayCache(), a = {}, b = {}; let disposed = 0;
|
||||||
const first = cache.acquire(a, 'same-id', async () => ({ url: 'blob:a', release: () => disposed++ }));
|
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' });
|
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);
|
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 () => {
|
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 cache = createDisplayCache(), gate = deferred(); let disposed = 0;
|
||||||
const lease = cache.acquire({}, 'asset', () => gate.promise); await tick();
|
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);
|
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();
|
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 () => {
|
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 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);
|
const invalid = cache.acquire({}, 'invalid', async () => ({ url: '', release: () => disposed++ })); await assert.rejects(invalid.ready, { code: 'invalid_display_source' }); assert.equal(disposed, 1);
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 分区解散和重新挂接测试。解散只移除 owning canvas,将其直接成员提升到父 canvas,保留入口元素及其连线。
|
||||||
|
* 几何换算必须保留世界姿态,子分区只改父绑定;声明的 canvas 引用跟随重映射,普通字符串不自动替换。
|
||||||
|
* 最后一例用真实 Foundry 协议适配器和内存服务检查单请求、丢 ACK 恢复及跨重开的 undo/redo。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createEditor,createRegistry,builtinComponents,defineComponent,movable,editable,validateDocument,applyChanges,canvasTransform,toWorld,unitScale} from '../../src/lib/canvas-core/index.ts';
|
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 {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
||||||
import {loadingCommand} from '../../src/lib/canvas-runtime/loading.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'}});
|
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}}]};
|
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'}});
|
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:[]}})]);
|
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'}}],[
|
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('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('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),
|
{...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}};
|
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}`);
|
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};}
|
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){
|
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'));
|
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'});
|
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);}
|
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);
|
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'});
|
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',()=>{
|
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++);
|
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);
|
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);
|
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',()=>{
|
test('dissolve rejects root, stale entry, member policy, unsupported member and opaque curve without partial publication',()=>{
|
||||||
const document=source();for(const options of[
|
const document=source();for(const options of[
|
||||||
{command:{...command,canvasId:'P'}},
|
{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',()=>{
|
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});
|
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]);
|
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',()=>{
|
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 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 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);
|
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',()=>{
|
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};
|
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);
|
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);
|
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()=>{
|
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]));
|
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 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=[];
|
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))});
|
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=>{
|
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))};
|
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);
|
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);
|
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};
|
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(){}};
|
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);
|
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{
|
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));
|
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 {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {createDuplicatePlanner,validateDuplicateCheckpoint} from '../../src/lib/canvas-core/duplicate-checkpoint.ts';
|
import {createDuplicatePlanner,validateDuplicateCheckpoint} from '../../src/lib/canvas-core/duplicate-checkpoint.ts';
|
||||||
import {planDuplicateResumable} from '../../src/lib/canvas-runtime/duplicate.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}}]}});
|
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')]);
|
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');};
|
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',()=>{
|
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'];
|
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(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;}
|
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',()=>{
|
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(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;}
|
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',()=>{
|
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/);
|
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/);
|
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()=>{
|
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);
|
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()=>{
|
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 {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||||
const {hasDetachedJsonData}=await import('../../src/lib/canvas-core/json-data.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);
|
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]);};
|
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('checkpoint-'))encoded++;return Reflect.apply(stringify,this,[value,...args]);};
|
||||||
try{
|
try{
|
||||||
|
// 封存检查点尚未完成时继续推进 planner,模拟存储异步执行和后续准备并行;已捕获检查点必须保持旧值。
|
||||||
const pending=sealRecoveryImageAsync(17,1,1,checkpoint,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirst??=encoded;yields++;}});
|
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();
|
for(let i=0;i<20000&&planner.progress().steps<=10;i++)planner.step();
|
||||||
assert.ok(planner.progress().steps>checkpoint.steps);
|
assert.ok(planner.progress().steps>checkpoint.steps);
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 复制规划的协作调度与检查点保存节奏测试。逐步扫描、分配及映射可以让出执行权,但最终计划仍需完全一致。
|
||||||
|
* 检查点保存比任务让出更稀疏,不能让慢 checkpoint I/O 本身强迫下一片立即再保存;取消或错误不发布半份计划。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,planDuplicateSteps,applyChanges,validateDocument,validateDocumentSteps,validJson} from '../../src/lib/canvas-core/index.ts';
|
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';
|
import {planDuplicateAsync,prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||||
|
// 使用内置复制定义,调度差异不会改变类型解释。
|
||||||
const registry=createRegistry(builtinComponents());
|
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}});
|
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:''}};
|
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]);
|
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}`};};
|
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()=>{
|
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++;}});
|
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);
|
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);
|
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()=>{
|
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 controller=new AbortController();let ids=0,ran=false;
|
||||||
const timer=setTimeout(()=>{ran=true;controller.abort();},0);
|
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);}
|
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()=>{
|
test('cancellation during allocation never returns a partial plan or changes the source',async()=>{
|
||||||
const controller=new AbortController();let allocated=0,slices=0;
|
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/);
|
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);
|
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()=>{
|
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 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}};
|
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);
|
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()=>{
|
test('time budget forces a task yield even when the step limit is high and scheduler errors propagate',async()=>{
|
||||||
let tick=0,yields=0;
|
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);
|
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/);
|
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()=>{
|
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}]])};
|
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/);
|
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/);
|
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()=>{
|
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 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());
|
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()=>{
|
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);
|
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);
|
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()=>{
|
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;
|
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);
|
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);
|
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);
|
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()=>{
|
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);}
|
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);}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 映射已准备好的复制源按记录游标懒输出测试,不要求先生成第二份完整输出 Blob。
|
||||||
|
* 来源摘要、registry 签名和稳定 ID 共同绑定准备状态;单条未知类型可报告记录错误,不破坏先前合法输出。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
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 {createDuplicatePlanSource} from '../../src/lib/canvas-runtime/duplicate-source.ts';
|
||||||
import {copyRegistrySignature} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
import {copyRegistrySignature} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||||
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.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;
|
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}}]}}]);
|
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});}
|
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()=>{
|
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);
|
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);}
|
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/);
|
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()=>{
|
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 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/);
|
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()=>{
|
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);
|
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}));
|
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}));
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 核心深复制产品规则测试:父子选择去重、分区树与引用映射、上级入边重接到副本,后代世界偏移只应用一次。
|
||||||
|
* 图片引用复用已有资源,执行任务元数据按 schema 省略;普通字面量和未知可保留字段不能被当 ID 随意替换。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,defineComponent,planDuplicate,applyChanges,validateDocument,canvasTransform,toWorld} from '../../src/lib/canvas-core/index.ts';
|
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 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}}]};
|
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}});
|
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 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')]);
|
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});
|
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});};
|
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}`);
|
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',()=>{
|
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);
|
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);
|
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);}
|
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',()=>{
|
test('geometry uses one world offset across rotated/scaled ownership and does not offset descendants twice',()=>{
|
||||||
const plan=fixture(),after=applyChanges(document,plan.changes);
|
const plan=fixture(),after=applyChanges(document,plan.changes);
|
||||||
for(const id of ['F','A','G','B','D']){
|
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);
|
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',()=>{
|
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;
|
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);
|
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',()=>{
|
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);
|
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/);
|
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/);
|
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',()=>{
|
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);
|
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);
|
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',()=>{
|
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 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);
|
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/);
|
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',()=>{
|
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'}]);
|
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/);
|
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/);
|
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',()=>{
|
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 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;
|
const plan=planDuplicate(source,registry,[target('F')],{createId:()=>`port-${++next}`}),data=applyChanges(source,plan.changes).elements.get(plan.elementIds.F).data;
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 持久化元数据订阅测试。durability 的脏状态、错误和关闭进度独立于业务 Root 发布。
|
||||||
|
* 内存 commit 可注入 quota 故障,观察者异常由错误回调接收,不应破坏其他观察者或保存状态。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.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();});
|
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();});
|
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();});
|
||||||
|
|||||||
@@ -1,48 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* ProjectEngine 的组合状态测试:文档、history、操作账本、ACK 基线由统一发布边界协调。
|
||||||
|
* 使用内存存储和持久化端口,验证保存顺序、未知结果、恢复会话、本地抵消与回执压缩;不等同于真实磁盘或服务端集成。
|
||||||
|
* Root 是不可变业务切点,durability 另行记录已确认保存到哪里;可见修改不等于安全离开。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createDocument, applyChanges, builtinComponents, createRegistry } from '../../src/lib/canvas-core/index.ts';
|
import { createDocument, applyChanges, builtinComponents, createRegistry } from '../../src/lib/canvas-core/index.ts';
|
||||||
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||||
import { encodeRecovery, decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
import { encodeRecovery, decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||||
|
|
||||||
|
// 内置类型参与全部模型校验,避免测试通过空注册表绕开数据约束。
|
||||||
const registry = createRegistry(builtinComponents());
|
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 } }]);
|
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 };
|
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 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(); };
|
const tick = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||||
|
// 内存 storage 持有独占 owner 和递增 fence;每次 commit 保存克隆镜像,并要求修订连续或重试同一修订。
|
||||||
function storageFixture() {
|
function storageFixture() {
|
||||||
let image = null, owner = null, fence = 0;
|
let image = null, owner = null, fence = 0;
|
||||||
const saved = [], fixture = {
|
const saved = [], fixture = {
|
||||||
broken: false, saved,
|
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 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); },
|
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 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() {},
|
async readFile() {}, close() {},
|
||||||
image: () => structuredClone(image), owner: () => owner,
|
image: () => structuredClone(image), owner: () => owner,
|
||||||
};
|
};
|
||||||
return fixture;
|
return fixture;
|
||||||
}
|
}
|
||||||
|
// 服务替身按请求 ID 去重,只在首次 submit 应用修改;query 查询既有结果,overrides 可替换任一协议边界。
|
||||||
function persistenceFixture(overrides = {}) {
|
function persistenceFixture(overrides = {}) {
|
||||||
let server = original, version = 1;
|
let server = original, version = 1;
|
||||||
const calls = [], receipts = new Map();
|
const calls = [], receipts = new Map();
|
||||||
const fixture = {
|
const fixture = {
|
||||||
calls, receipts,
|
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)])) }; },
|
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) {
|
async submit(request) {
|
||||||
calls.push(['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]]) }); }
|
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 } };
|
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' }; },
|
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,
|
canonical(request) { return receipts.get(request.id); }, ...overrides,
|
||||||
};
|
};
|
||||||
return fixture;
|
return fixture;
|
||||||
}
|
}
|
||||||
|
// 统一装配 engine 并收集异步错误;extra 可覆盖时钟、ID、文档、registry 等默认装配选项。
|
||||||
async function open(storage, persistence, extra = {}) {
|
async function open(storage, persistence, extra = {}) {
|
||||||
let next = 0; const errors = [];
|
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 });
|
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 };
|
return { engine, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 本地命令一次发布文档、历史和账本;首个请求在途时继续移动,ACK 后基于新版本重放移动,最终两次修改都持久化。
|
||||||
test('local document/history/ledger publish together; ACK replays edits made while a request is in flight', async () => {
|
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;
|
const storage = storageFixture(), port = persistenceFixture(), gate = deferred(), realSubmit = port.submit;
|
||||||
port.submit = async request => { if (!port.calls.length) await gate.promise; return realSubmit(request); };
|
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();
|
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 () => {
|
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;
|
const storage = storageFixture(), port = persistenceFixture(), realSubmit = port.submit;
|
||||||
port.submit = async request => { await realSubmit(request); return { outcome: 'unresolved' }; };
|
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();
|
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()=>{
|
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;
|
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;};
|
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();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// quota 失败保留可见编辑但阻止网络;失败 close 保留存储 owner 并封闭编辑入口,存储恢复后重试 close 才释放。
|
||||||
test('failed local durability preserves editing, prevents sends, and failed close retains the recovery leader', async () => {
|
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);
|
const storage = storageFixture(), port = persistenceFixture(), { engine, errors } = await open(storage, port);
|
||||||
await engine.persist(); storage.broken = true;
|
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);
|
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 () => {
|
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;
|
const storage = storageFixture(), port = persistenceFixture(), gate = deferred(), realSubmit = port.submit;
|
||||||
port.submit = async request => { await gate.promise; return realSubmit(request); };
|
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();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 编码解码恢复只读集合及历史;缺失历史引用和自依赖均被拒绝,另一个镜像副本的修改不改变已恢复内容。
|
||||||
test('recovery codec restores immutable maps and rejects broken history/dependency references', async () => {
|
test('recovery codec restores immutable maps and rejects broken history/dependency references', async () => {
|
||||||
const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port);
|
const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port);
|
||||||
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'encoded' } }); await engine.settle();
|
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();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 无未决写入时可接纳更新规范基线并保留历史;旧版本刷新被拒绝,不回退新几何。
|
||||||
test('a fresh canonical read replaces a clean recovered base, keeps history and refuses older versions', async () => {
|
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);
|
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();
|
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();
|
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()=>{
|
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();
|
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);};
|
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();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未知回执不能靠全量读跳过确认;prepareRecovery 失败后保留原请求与本地状态,并解除临时编辑屏障。
|
||||||
test('unknown receipts prevent a recovery read and preserve the frozen request identity',async()=>{
|
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 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();
|
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();
|
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()=>{
|
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 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]])};}});
|
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();
|
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()=>{
|
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 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;
|
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();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 关闭引擎 abort 恢复读取并释放 owner;端口仍返回迟到结果也必须拒绝,不能创建恢复会话。
|
||||||
test('closing the engine aborts a recovery read and rejects a late canonical response',async()=>{
|
test('closing the engine aborts a recovery read and rejects a late canonical response',async()=>{
|
||||||
const storage=storageFixture(),response=deferred();let signal;
|
const storage=storageFixture(),response=deferred();let signal;
|
||||||
const port=persistenceFixture({async read(input){signal=input;return response.promise;}}),{engine}=await open(storage,port);
|
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);
|
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()=>{
|
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 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);
|
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();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同一事务先创建再删除,basePresence 必须保留最初 absent;本地留下墓碑但无需发送,也不能伪造 ACK 墓碑。
|
||||||
test('one transaction captures the first presence baseline even when its created element is later deleted', async () => {
|
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);
|
const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port);
|
||||||
try {
|
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 () => {
|
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 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;};
|
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 () => {
|
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);
|
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port);
|
||||||
try {
|
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 () => {
|
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;
|
const storage=storageFixture(),port=persistenceFixture();let engine=(await open(storage,port)).engine;
|
||||||
try{
|
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()=>{
|
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;
|
const storage=storageFixture(),port=persistenceFixture();let next=0,engine=(await open(storage,port,{createId:()=>`repeat-${++next}`})).engine;
|
||||||
try{
|
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()=>{
|
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);
|
const storage=storageFixture(),port=persistenceFixture({compileBatch(){throw Error('closed lifetime entered the wire batch');}}),{engine,errors}=await open(storage,port);
|
||||||
try{
|
try{
|
||||||
@@ -312,73 +351,96 @@ test('batch-capable persistence sends an earlier real edit separately and never
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 不主动触发重试的假时钟;定时器仅返回 token,使持久化竞争用例集中观察显式保存和在途网络。
|
||||||
const idleClock=()=>({now:()=>0,setTimer:()=>Symbol('idle'),clearTimer(){}});
|
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()=>{
|
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{
|
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;}});
|
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);};
|
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');
|
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();
|
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);
|
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();}
|
}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()=>{
|
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{
|
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);};
|
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);
|
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);
|
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();}
|
}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()=>{
|
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{
|
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();}
|
}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()=>{
|
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{
|
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);
|
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();}
|
}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()=>{
|
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{
|
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');
|
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();}
|
}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()=>{
|
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{
|
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);
|
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();}
|
}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()=>{
|
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);
|
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);
|
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();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 摘要准备后最终模型校验失败,必须保留原始成功回执和 waiting 占用;校验恢复后查询修复,不第二次 submit。
|
||||||
test('a failed final publication keeps the raw receipt and waiting claim instead of the prepared summary',async()=>{
|
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});
|
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'));
|
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();}
|
}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()=>{
|
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();}}
|
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()=>{
|
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;
|
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;}}}});
|
Object.defineProperty(globalThis,'scheduler',{configurable:true,value:{async yield(){yields++;if(hold&&!paused){paused=true;entered.resolve();await resume.promise;}}}});
|
||||||
let engine;
|
let engine;
|
||||||
try{
|
try{
|
||||||
|
|
||||||
|
// 超过编码让出阈值的大模型;每个记录合法且 ID 独立,避免用编码失败替代调度竞争。
|
||||||
const rows=Array.from({length:17000},(_,i)=>({...original.elements.get('a'),id:i===0?'a':`encoding-${i}`}));
|
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'};}});
|
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();
|
({engine}=await open(storage,port,{document,clock:idleClock()}));await engine.persist();
|
||||||
const start=engine.getSnapshot().rootRevision,saved=storage.saved.length;hold=true;
|
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');
|
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;
|
const captured=engine.getSnapshot(),first=engine.persist();await entered.promise;
|
||||||
assert.equal(storage.saved.length,saved);assert.equal(port.calls.length,0);
|
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');
|
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');
|
const second=engine.persist();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'edited-during-yield');
|
||||||
resume.resolve();await Promise.all([first,second]);
|
resume.resolve();await Promise.all([first,second]);
|
||||||
|
|
||||||
|
// 比较完整首镜像与同步 encodeRecovery(captured),验证让出主线程后没有混入后来编辑;同时检查修订序列无空洞。
|
||||||
const cuts=storage.saved.slice(saved);assert.ok(yields>0);
|
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.map(cut=>cut.rootRevision),Array.from({length:cuts.length},(_,i)=>start+i+1));
|
||||||
assert.deepEqual(cuts[0],encodeRecovery(captured));
|
assert.deepEqual(cuts[0],encodeRecovery(captured));
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 画布入口的 canonical-first 选择测试。已迁移项目直接进入 headless,只有明确迁移需求或未部署接口可走旧快照。
|
||||||
|
* 权限失败不能降级成旧数据读取;入口只保留已验证迁移材料,不自动发迁移请求。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
|
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
|
||||||
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.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')}`;
|
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 };
|
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());
|
const profile = { id: id(3) }, registry = createRegistry(builtinComponents());
|
||||||
|
// request 提供 profile/board/旧 canvas,transport 返回注入 canonical 响应;所有路径记录到 calls 检查不应发生的 fallback。
|
||||||
function fixture(response) {
|
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; };
|
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; }; } } };
|
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 () => {
|
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 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);
|
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);
|
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 () => {
|
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 } } }]) {
|
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);
|
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);
|
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()=>{
|
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 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);
|
const result=await loadCanvasEntry(f.options);
|
||||||
|
|||||||
@@ -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 assert from 'node:assert/strict';
|
||||||
import {randomUUID,createHash} from 'node:crypto';
|
import {randomUUID,createHash} from 'node:crypto';
|
||||||
|
|
||||||
|
// 组织整套有状态集成场景并返回已通过的检查标记;任一断言失败都进入 finally 关闭引擎。
|
||||||
export async function verifyFoundryAI({open,transport,rootId,imageId,assets,calls,tick,readDisplay,advance,loseAdmission,loseAction}){
|
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`;
|
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;};
|
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));
|
const posts=suffix=>calls.filter(call=>call.method==='POST'&&call.path.endsWith(suffix));
|
||||||
|
// 保存当前引擎实例;重开时替换该引用,后续断言读取恢复后的同一项目。
|
||||||
let engine=await open();const signal=new AbortController().signal;
|
let engine=await open();const signal=new AbortController().signal;
|
||||||
|
// 正常关闭释放资源与存储占用,推进调用方时钟后重新打开并等待恢复流程收敛。
|
||||||
const reopen=async()=>{await engine.close();advance();engine=await open();await engine.settle();};
|
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');};
|
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{
|
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'));
|
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};
|
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();
|
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};
|
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;};
|
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 byOperation=id=>[...engine.getSnapshot().runtime.runs.values()].find(run=>run.operationId===id);
|
||||||
|
// 记录本场景开始前的调用基数,末尾核对生成次数和图片复制是否引入额外上传。
|
||||||
const initialStarts=posts('/ai/runs').length,initialUploads=posts('/assets').length;
|
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]));
|
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;
|
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);
|
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);
|
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}))}));
|
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');
|
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();
|
assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:77}}).status,'applied');await engine.settle();
|
||||||
await control('execute');await poll();
|
await control('execute');await poll();
|
||||||
const generatedText=engine.getSnapshot().runtime.runs.get(firstRun.id);
|
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.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});
|
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;
|
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();
|
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;
|
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');
|
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);
|
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');
|
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}));
|
const executing=control('execute').then(value=>({value}),error=>({error}));
|
||||||
try{
|
try{
|
||||||
await control('wait');
|
await control('wait');
|
||||||
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'user edit while executing'}}).status,'applied');await engine.settle();
|
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');}
|
}finally{await control('release');}
|
||||||
const completed=await executing;if(completed.error)throw completed.error;await poll();
|
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 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');
|
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();
|
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;
|
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();
|
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(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);
|
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 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 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();
|
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);
|
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);
|
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();}
|
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 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);
|
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);
|
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};
|
return {runtimeAI:true,aiLostAdmission:true,aiCancelReconciled:true,aiConflictApplyUndo:true,aiManagedImage:true,aiCopyNoRun:true,aiExecutionCount:3};
|
||||||
|
// 成功或失败都关闭最新引擎实例,释放本次打开的运行时资源。
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 assert from 'node:assert/strict';
|
||||||
import {randomUUID} from 'node:crypto';
|
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 {createCanvasMigration} from '../../src/lib/canvas-migration.ts';
|
||||||
import {verifyFoundryAI} from './foundry-ai.integration.mjs';
|
import {verifyFoundryAI} from './foundry-ai.integration.mjs';
|
||||||
import {decodeModelStream} from '../../src/lib/canvas-service/model-stream.ts';
|
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;
|
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');
|
assert.ok(url&&project&&accountId&&workspaceId&&rootId&&token,'requires isolated Foundry HTTP fixture');
|
||||||
|
// 装配实际模型校验与项目 API 根路径,calls 只记录测试需要的协议信息。
|
||||||
const registry=createRegistry(builtinComponents()),basePath=`${url}/projects/${project}/`,calls=[];
|
const registry=createRegistry(builtinComponents()),basePath=`${url}/projects/${project}/`,calls=[];
|
||||||
|
// 各故障开关只在相应成功响应已经返回后生效,不把发送前失败伪装成“服务已提交但响应丢失”。
|
||||||
let loseNextAck=false,loseNextAssetAck=false,loseNextAIAdmission=false,loseNextAIAction=false;
|
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});};
|
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 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`;
|
const path=`workspaces/${workspaceId}/materials/${rootId}/canvas`;
|
||||||
|
// fixture 初始必须是待迁移旧画布;这保证随后恢复的是迁移写入而不是现成 canonical 模型。
|
||||||
const migration=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(migration.status,'migration-required');
|
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)};
|
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()};
|
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);
|
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;
|
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((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);
|
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 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);
|
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');
|
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);
|
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;
|
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');}};
|
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 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();
|
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;};
|
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);}};
|
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});
|
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}});
|
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();
|
let engine=await open();
|
||||||
try{
|
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;
|
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);
|
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');
|
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};
|
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();
|
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}));
|
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));
|
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);
|
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);
|
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
|
// 解散修改 owning tree,不递归删除入口或后代;真实 DB 成功后丢 ACK,再查询恢复并重做同一历史单元。
|
||||||
// real DB response, reopen/query, then restore and repeat the same history unit.
|
|
||||||
loseNextAck=true;assert.equal(engine.editor.dispatch({type:'canvas.dissolve',canvasId:childId,entry:{id:frame.id,incarnation:1}}).status,'applied');await engine.settle();
|
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');
|
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);
|
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});
|
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();
|
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);
|
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);
|
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
|
// 尾部 ID 严格大于全部原 ID,且属于另一 owner;真实 64 项首帧可认证根/新子分区,但原有子分区仍缺成员。
|
||||||
// lower than these records, so the first real 64-record frame certifies the
|
// 收集现有元素、canvas 与墓碑 ID,计算严格更大的 70 个尾部 ID,使流排序和缺失归属可预测。
|
||||||
// root/new child while the original child is provably still incomplete.
|
|
||||||
const namespace=[...engine.getSnapshot().document.elements.keys(),...engine.getSnapshot().document.canvases.keys(),...engine.getSnapshot().document.tombstones.keys()];
|
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 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);});
|
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();
|
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
|
// 消费生产解码器的真实协商流,并暂停在首个非空帧;旧捕获尾部到达前,HTTP/DB ACK 可继续推进。
|
||||||
// after the first nonempty frame. HTTP/DB ACKs may advance before its old tail.
|
|
||||||
await engine.close();image=null;initial=await loadFoundryModel(transport,workspaceId,rootId,registry);
|
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 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);
|
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;
|
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;}
|
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);
|
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};
|
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);
|
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);
|
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;
|
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 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);
|
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)));
|
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);
|
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');
|
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));
|
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);
|
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);
|
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
|
// 同 workspace 的第二个 canonical root 采用已有授权资源;共享字节保留,真实服务独立验证新引用,无需第二次上传。
|
||||||
// authorized identity. Shared bytes remain local; the real server validates
|
|
||||||
// the new document reference independently, with no second upload.
|
|
||||||
await engine.close();
|
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 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 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 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)};
|
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);
|
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);
|
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'){
|
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;}});
|
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(','));
|
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}));
|
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();}
|
}finally{await engine.close();}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 图片解码池的并发、排队、取消与响应校验测试。尺寸元数据必须合法,调用者取消不能提前释放仍在运行的槽位。
|
||||||
|
* 使用解码执行器及 Worker 替身,不实际解码图片像素或启动浏览器线程。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createImageDecodePool} from '../../src/lib/canvas-runtime/image-decoder.ts';
|
import {createImageDecodePool} from '../../src/lib/canvas-runtime/image-decoder.ts';
|
||||||
import {createBrowserImageDecoder} from '../../src/lib/canvas-runtime/browser-images.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'});
|
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()=>{
|
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();}
|
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()=>{
|
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();}
|
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()=>{
|
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;}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 导入发布批量的自适应控制测试。目标耗时保持不变,根据固定发布成本与每项成本增减批量,而不是固定每批若干项。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createImportBatches} from '../../src/lib/canvas-runtime/import-batches.ts';
|
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',()=>{
|
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=[];
|
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);}
|
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));
|
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',()=>{
|
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);
|
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 {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {openStreamingDocumentImportSource} from '../../src/lib/canvas-runtime/import-document-stream.ts';
|
import {openStreamingDocumentImportSource} from '../../src/lib/canvas-runtime/import-document-stream.ts';
|
||||||
|
// 为各次读取生成独立信号,测试可单独取消某个挂起读取而不使整个源永久不可用。
|
||||||
const signal=()=>new AbortController().signal;
|
const signal=()=>new AbortController().signal;
|
||||||
|
// 最小标准 envelope 头部,后续用例在其后追加 elements/tombstones 或打乱字段顺序。
|
||||||
const header={documentFormatVersion:1,projectId:'source',canvases:[{id:'root',parent:null}]};
|
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});
|
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};};
|
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()=>{
|
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;
|
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();}};}}
|
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);
|
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);
|
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);
|
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()=>{
|
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 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/);
|
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()=>{
|
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}',',}']){
|
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);
|
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');
|
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()=>{
|
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');
|
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/);
|
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()=>{
|
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);
|
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()=>{
|
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);}}
|
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);
|
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}`);
|
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`);
|
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()=>{
|
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);
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
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}};
|
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()});
|
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);});
|
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)));});
|
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/);});
|
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 {prepareNativeImport} from '../../src/lib/canvas-runtime/import-native.ts';
|
||||||
import {createImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
import {createImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
||||||
import {createRegistry,createDocument} from '../../src/lib/canvas-core/index.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'));});
|
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);});
|
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,19 @@
|
|||||||
|
/**
|
||||||
|
* 导入稳定 ID 表的不可变捕获与增量分配测试。安全冻结表可缓存反向身份集合,外部可变/getter 表必须防御性捕获。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createImportIdentities} from '../../src/lib/canvas-runtime/import-identities.ts';
|
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',()=>{
|
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);
|
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',()=>{
|
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/);
|
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',()=>{
|
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');
|
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);
|
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);
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 导入源全文件 SHA-256 绑定与任务完整性版本测试。新任务在绑定摘要前不能声明已解析游标或稳定映射,旧格式按明确兼容规则读取。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
|
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
|
||||||
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.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()=>{
|
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'])));
|
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',()=>{
|
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);
|
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/);
|
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/);
|
||||||
|
|||||||
@@ -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';
|
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;
|
const signal=()=>new AbortController().signal;
|
||||||
|
// 把文本包装成 Blob,绑定 fingerprint,并允许各用例覆盖 chunkBytes、单条上限及调度端口。
|
||||||
function source(text,options={}){return createJsonArrayImportSource({bytes:new Blob([text]),fingerprint:'immutable-source',...options});}
|
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');}
|
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('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');});
|
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');});
|
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/);});
|
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);});
|
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);});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 导入按块读取证明测试。证明只记录成功消费范围涉及的对齐块摘要,不为尚未读到的文件尾部提供真实性保证。
|
||||||
|
* 原始块缓存可淘汰,但预期摘要保留;共享读取、取消、关闭与容量限制不能提前发布半份读取证据。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createImportReadProof,validateImportReadProof,IMPORT_PROOF_BLOCK_BYTES as B} from '../../src/lib/canvas-runtime/import-read-proof.ts';
|
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 {createNdjsonImportSource} from '../../src/lib/canvas-runtime/imports.ts';
|
||||||
import {createJsonArrayImportSource} from '../../src/lib/canvas-runtime/import-json-array.ts';
|
import {createJsonArrayImportSource} from '../../src/lib/canvas-runtime/import-json-array.ts';
|
||||||
import {fault} from '../../src/lib/canvas-core/values.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};};
|
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');
|
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));
|
const tick=()=>new Promise(resolve=>setTimeout(resolve,0));
|
||||||
|
|
||||||
|
// 跨两个块的范围返回精确且独立的字节副本,调用者修改不影响重读;证明只含消费过的对齐块,缓存可仅驻留一个块。
|
||||||
test('range reads return exact owned bytes and checkpoint only the aligned blocks they consumed',async()=>{
|
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 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));
|
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);
|
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()=>{
|
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 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();
|
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()=>{
|
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()})]];
|
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();}
|
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()=>{
|
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 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();
|
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();
|
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()=>{
|
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();
|
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()=>{
|
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();
|
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()=>{
|
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 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/);
|
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()=>{
|
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));
|
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();
|
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()=>{
|
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();
|
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 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();
|
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()=>{
|
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 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());}};
|
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()=>{
|
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 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());}};
|
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());}};
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* ProjectEngine 的渐进导入/深复制集成测试,串联本地文档、history、ledger、源文件、校验及恢复。
|
||||||
|
* 已发布内容可早于服务端 ACK;分块读取证明支持先显示,完整 SHA 与其持久化 seal 决定何时解除相关网络写入门槛。
|
||||||
|
* 暂停/关闭/取消、端点换代、旧解释器、资源引用及源文件回收各有独立生命周期,不能用重新插入全部内容来恢复。
|
||||||
|
* fixture 使用内存存储和幂等假服务端;测试验证引擎协调,不等同于真实 IDB 事务、网络或渲染帧率验证。
|
||||||
|
*/
|
||||||
import {encodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
import {encodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||||
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
|
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
|
||||||
import {test} from 'node:test';
|
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 {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.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'};
|
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}}});
|
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')]);
|
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};};
|
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
|
||||||
|
// 准备可重用的源文件/image/服务端文档和回执;broken 在写入前抛配额错误,gate 可挂起服务端提交。
|
||||||
function fixture(initial=original){
|
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=[];
|
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,
|
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(){}},
|
storage:{fileDeletion:'atomic-v1',
|
||||||
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)};}},
|
// 单项目仅一个 leader;release 清空 owner 并撤销活动标记,允许关闭后重新领取。
|
||||||
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;
|
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();}});});}
|
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()=>{
|
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;
|
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();}
|
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()=>{
|
test('pause/reopen/resume reuses the durable source and IDs without overwriting a user edit',async()=>{
|
||||||
const f=fixture();let engine=await f.open();
|
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();}
|
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()=>{
|
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();
|
const f=fixture();let engine=await f.open();
|
||||||
try{
|
try{
|
||||||
@@ -42,64 +83,76 @@ test('import history on either side of a manual edit remains three ordered undo
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 取消只保留已加入首项;重开后显式 resume 也不把 cancelled 任务当暂停任务继续插入。
|
||||||
test('cancel keeps the already added prefix and survives reopening without further insertion',async()=>{
|
test('cancel keeps the already added prefix and survives reopening without further insertion',async()=>{
|
||||||
const f=fixture();let engine=await f.open();
|
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();}
|
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()=>{
|
test('deleting an imported endpoint while paused prevents a late edge from reviving it',async()=>{
|
||||||
const f=fixture();let engine=await f.open();
|
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:{}}};
|
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();}
|
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()=>{
|
test('canonical recovery holds active imports and releases them after accepting the fresh base',async()=>{
|
||||||
const f=fixture(),engine=await f.open();
|
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();}
|
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()=>{
|
test('closing an active import preserves a running checkpoint and reopening resumes without duplicate records',async()=>{
|
||||||
const f=fixture();let engine=await f.open();
|
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();}
|
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()=>{
|
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:{}}};
|
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();}
|
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()=>{
|
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();
|
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();}
|
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()=>{
|
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();
|
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();}
|
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()=>{
|
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);};
|
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();}
|
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()=>{
|
test('resuming after a failed source write preserves IDs and wakes the original durable writer',async()=>{
|
||||||
const f=fixture(),engine=await f.open();
|
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();}
|
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}});
|
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){
|
async function seedCopy(engine){
|
||||||
const records=[copyNode('C'),copyNode('F','canvas','frame'),copyNode('A','child','image'),copyNode('B','child')];
|
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 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();
|
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()=>{
|
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();
|
const f=fixture();let engine=await f.open();
|
||||||
try{
|
try{
|
||||||
@@ -113,6 +166,7 @@ test('deep copy source/mapping recover and preserve upper incoming source, share
|
|||||||
}finally{await engine.close();}
|
}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()=>{
|
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();
|
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();
|
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();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 复制任务取消后仅保留已发布前缀,重开/resume 不再新增副本。
|
||||||
test('copy cancellation retains the published prefix and does not resume on reopening',async()=>{
|
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();}
|
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()=>{
|
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 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();}
|
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()=>{
|
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();}
|
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()=>{
|
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();}
|
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()=>{
|
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();}
|
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)=>{
|
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{
|
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();
|
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();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 任务已准入但仍 preparing 时关闭,保存任务与快照,重开自动完成五个源键。
|
||||||
test('closing an admitted preparing copy preserves its task and snapshot for automatic restart',async()=>{
|
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();}
|
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()=>{
|
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();}
|
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()=>{
|
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 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);
|
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();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 恢复会话 hold 期间 preparing 任务不前进,接纳协调后的状态根后才重新启动并完成。
|
||||||
test('canonical recovery holds a preparing copy and restarts it only after the coordinated root is adopted',async()=>{
|
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();}
|
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()=>{
|
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 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'}}};
|
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{
|
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 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 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);
|
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();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 源文件保存一直挂起时允许有限本地前缀,未持久化根差达到背压门槛后停止增长;所有网络请求仍为零,放行才完成。
|
||||||
test('blocked source persistence admits several local batches then applies bounded backpressure and gates every request',async()=>{
|
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;
|
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),commit=f.storage.commit;
|
||||||
try{
|
try{
|
||||||
@@ -191,14 +258,17 @@ test('blocked source persistence admits several local batches then applies bound
|
|||||||
}finally{gate.resolve();await engine.close();}
|
}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 复制快照字节同长度篡改也会被摘要绑定识别;恢复前无新副本/网络写,原摘要不被替换成篡改源的身份。
|
||||||
test('same-length changes to an admitted copy snapshot are rejected before preparation allocates or publishes content',async()=>{
|
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();}
|
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()=>{
|
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();}
|
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()=>{
|
test('late source digest cannot revive a cancelled job or publish into a held recovery root',async()=>{
|
||||||
for(const cancel of [true,false]){
|
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'))]);
|
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()=>{
|
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();}
|
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);}
|
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()=>{
|
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();}
|
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()=>{
|
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();
|
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);
|
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();}
|
}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()=>{
|
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();}}
|
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()=>{
|
test('late planned-source decoding respects cancellation and canonical recovery holds',async()=>{
|
||||||
for(const cancel of [true,false]){
|
for(const cancel of [true,false]){
|
||||||
const f=fixture();let engine=await f.open();const entered=deferred(),gate=deferred();try{
|
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();
|
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();}}
|
/* arrayBuffer 真正返回前发出 entered 并等待 gate,以制造取消/恢复期间的迟到正文。 */ 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;}}
|
/* 仅拦住 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;
|
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);
|
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();}
|
}finally{gate.resolve();await engine.close();}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 索引已可用时,晚到大正文块挂起不阻塞前缀副本可见;放行后才读取剩余内容并完成全部副本。
|
||||||
test('indexed copy displays a prefix while a later raw payload read is still blocked',async()=>{
|
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 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;
|
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;}
|
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();}}
|
/* arrayBuffer 真正返回前发出 entered 并等待 gate,以制造取消/恢复期间的迟到正文。 */ 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;}}
|
/* 仅从正文区第二个对齐 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);
|
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();}
|
}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 两个项目共享摘要池,关闭一个只取消它的请求,不关闭池或影响另一个项目;被关闭项目重开后仍可继续。
|
||||||
test('closing a project aborts its injected digest without closing another project sharing the pool',async()=>{
|
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});
|
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();}
|
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)};};
|
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()=>{
|
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 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 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 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'));
|
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/);
|
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/);
|
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);
|
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();}
|
}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()=>{
|
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 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();
|
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();}
|
}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()=>{
|
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 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);};
|
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();}
|
}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()=>{
|
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 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);
|
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();}
|
}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()=>{
|
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{
|
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);
|
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();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// openSource 验证当前文件与绑定摘要;篡改拒绝并释放 pin,读取期间关闭项目则迟到结果报 engine_closing。
|
||||||
test('source read handles verify bound bytes and never publish a result after project close',async()=>{
|
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 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);
|
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();}
|
}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();}});
|
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();}});
|
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();}});
|
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('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();}});
|
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()=>{
|
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();
|
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{
|
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');
|
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'));
|
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);
|
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');
|
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/);
|
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);
|
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();}
|
}finally{block=false;gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 文档 footer 墓碑非法时已导入元素仍可见并同步;partial 及错误诊断持久化,重开保留该成功前缀。
|
||||||
test('bad document footer produces durable partial success rather than discarding imported content',async()=>{
|
test('bad document footer produces durable partial success rather than discarding imported content',async()=>{
|
||||||
const f=fixture();let engine=await f.open();try{
|
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);
|
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();}
|
}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()=>{
|
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 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();
|
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();}
|
}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()=>{
|
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;
|
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{
|
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');
|
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);
|
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();}
|
}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()=>{
|
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);}}});
|
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();}
|
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()=>{
|
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);}}});
|
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();}
|
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()=>{
|
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();}}
|
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()=>{
|
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);}}});
|
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();}
|
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()=>{
|
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);});});}}});
|
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();}
|
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()=>{
|
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);});});}}});
|
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();}
|
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()=>{
|
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));});}}});
|
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();}
|
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()=>{
|
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();}
|
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()=>{
|
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();}
|
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()=>{
|
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();}}
|
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()=>{
|
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 {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'}));
|
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})));
|
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 until(engine,snapshot=>snapshot.runtime.imports.get(id)?.source?.kind==='duplicate-plan');
|
||||||
await engine.imports.pause(id);unsubscribe();
|
await engine.imports.pause(id);unsubscribe();
|
||||||
|
// 等待发布监听里启动的封存全部完成,检查准备与源两个阶段都被捕获,且编码期间后续变更未改写旧值。
|
||||||
const results=await Promise.all(captured);assert.deepEqual([...seen].sort(),['preparation','source']);
|
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);}
|
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();
|
const ids=engine.imports.getJobs().get(id).stableIds;gate.resolve();await engine.close();engine=await f.open();
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 独立导入 runner 的解析、依赖分组、发布和持久化状态机测试。每个已提交前缀可见,坏记录及取消不默认回滚全部导入。
|
||||||
|
* 依赖循环作为强连通组原子提交;稳定 ID、待处理定位、源身份和 generation 支持恢复并隔离迟到异步准备。
|
||||||
|
* fixture 用内存文档与克隆 cut 验证端口调用顺序;引擎/真实存储集成另有测试,不由此模拟持久层事务保证。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,applyChanges,validateDocument,createRegistry,builtinComponents,validJson,fault} from '../../src/lib/canvas-core/index.ts';
|
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';
|
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}],[]);
|
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 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();};
|
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')]);
|
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={}){
|
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})};
|
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,
|
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};},
|
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}}}]};},
|
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:[]};}},
|
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]}}));},
|
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};
|
onError:error=>errors.push(error),yieldTask:async()=>{f.yields++;},...options.ports},yields:0};
|
||||||
|
// 所有端口装好后才创建 runner,避免调用未就绪 fixture;返回端口引用供各用例精确注入故障。
|
||||||
f.runner=createImportRunner(f.ports);return f;
|
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()=>{
|
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;
|
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;}
|
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');
|
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()=>{
|
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;
|
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;
|
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();
|
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()=>{
|
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;};
|
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);
|
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()=>{
|
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);
|
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()=>{
|
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)));
|
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()=>{
|
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 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;
|
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);
|
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()=>{
|
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 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;
|
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);}
|
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()=>{
|
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 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);
|
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',()=>{
|
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/);
|
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()=>{
|
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);};
|
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);
|
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()=>{
|
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 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}});
|
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);
|
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()=>{
|
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;
|
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;};
|
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);
|
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()=>{
|
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}});
|
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);
|
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()=>{
|
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);
|
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()=>{
|
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);
|
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()=>{
|
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 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);
|
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()=>{
|
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);
|
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()=>{
|
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;
|
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;}};
|
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));
|
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()=>{
|
test('both final content and completion-metadata storage failures pause resumably without replaying committed keys',async()=>{
|
||||||
for(const phase of ['content','metadata']){
|
for(const phase of ['content','metadata']){
|
||||||
const f=fixture([{id:'one'},{id:'two'}],{ports:{optimisticLocal:true,awaitDurabilityBudget(){}}}),persist=f.ports.persist;let broken=true;
|
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()=>{
|
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;
|
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);
|
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()=>{
|
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;
|
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);
|
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()=>{
|
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();
|
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();});
|
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();});
|
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();});
|
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);});
|
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()=>{
|
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();
|
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()=>{
|
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();
|
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()=>{
|
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);
|
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()=>{
|
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();
|
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()=>{
|
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();
|
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()=>{
|
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 {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();
|
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()=>{
|
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 {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();
|
const f=fixture(Array.from({length:300},(_,i)=>({id:'waiting-'+i,dependencies:['absent']})));await f.runner.start();
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 作用域加载协调器测试:多个订阅者可共享同一渐进源,但各自保留导航元数据、取消状态和快照生命周期。
|
||||||
|
* 块需绑定 query/权限/project/snapshot/version/sequence,完整标记后仍须正常 EOF;错误尾部只能保留只读前缀。
|
||||||
|
* 活动并发、排队、单响应字节/块数及校验后缓存分别受限,旧任务迟到清理不能删除新代次。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createScopedLoader, loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
|
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 };
|
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 }; };
|
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));
|
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 });
|
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 () => {
|
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 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); } });
|
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.strictEqual(a.getSnapshot(),stopped);assert.strictEqual(b.getSnapshot(),result);
|
||||||
assert.deepEqual(updates, ['queued', 'loading', 'initial-ready']); b.release(); loader.close();
|
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 () => {
|
test('tail failure keeps the accepted prefix and a fresh retry replaces the failed inflight entry', async () => {
|
||||||
let calls = 0;
|
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 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 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();
|
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 () => {
|
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 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/);
|
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();
|
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 () => {
|
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' }]) {
|
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 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();
|
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 () => {
|
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 } }]) {
|
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 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();
|
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 () => {
|
test('an incomplete EOF or extra chunk after a complete marker cannot become a complete result', async () => {
|
||||||
for (const extra of [false, true]) {
|
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 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();
|
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 () => {
|
test('response byte and chunk budgets bound retained read copies while preserving earlier chunks', async () => {
|
||||||
for (const limits of [{ maxResponseBytes: 3 }, { maxChunks: 1 }]) {
|
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 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();
|
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 () => {
|
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 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); } });
|
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);
|
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();
|
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 () => {
|
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 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 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();
|
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 () => {
|
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();
|
let time = 100, accepted = true; const calls = new Map();
|
||||||
const loader = createScopedLoader({ maxCacheEntries: 10, maxCacheBytes: 4, cacheMaxAgeMs: 100, now: () => time, acceptCached: () => accepted, current: () => true,
|
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); } });
|
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(); };
|
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);
|
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);
|
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();
|
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 () => {
|
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); } });
|
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));
|
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();
|
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 () => {
|
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 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 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();
|
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 () => {
|
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 gate = deferred(); let current = true;
|
||||||
const loader = createScopedLoader({ current: () => current, async *load(query) { await gate.promise; yield chunk(query); } });
|
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 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' });
|
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 () => {
|
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 errors = []; let peer, updates = 0;
|
||||||
const loader = createScopedLoader({ current: () => true, onObserverError: error => errors.push(error), async *load(query) { yield chunk(query); } });
|
const loader = createScopedLoader({ current: () => true, onObserverError: error => errors.push(error), async *load(query) { yield chunk(query); } });
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 部分模型上的可编辑 ProjectEngine 集成测试。loading v1–v6 是加载状态解释器版本,逐步加入范围证书、提前同步、结构/任务/资源准入。
|
||||||
|
* working、原始 loadingRead 与最新 ACK 各有不同用途:后续旧流不能覆盖已确认编辑,未知请求也不能被新完整读当作未执行。
|
||||||
|
* 局部允许的操作仍要验证实际 changes、引用闭包及持久化证据,不能仅按命令名放行;完整接纳后才移除加载边界。
|
||||||
|
* fixture 使用内存存储和 Foundry persistence adapter 加假 transport,验证协调与协议检查,不证明真实服务或浏览器表现。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {randomUUID} from 'node:crypto';
|
import {randomUUID} from 'node:crypto';
|
||||||
@@ -5,26 +11,53 @@ import {createDocument,applyChanges,createRegistry,builtinComponents} from '../.
|
|||||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||||
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
||||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||||
|
// 内置定义与 root/child UUID,满足 Foundry 协议的作用域校验。
|
||||||
const registry=createRegistry(builtinComponents()),root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002';
|
const registry=createRegistry(builtinComponents()),root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002';
|
||||||
|
// 固定账户、workspace 与项目,使恢复读取命中同一持久化状态。
|
||||||
const scope={accountId:'10000000-0000-4000-8000-000000000003',workspaceId:'10000000-0000-4000-8000-000000000004',projectId:root};
|
const scope={accountId:'10000000-0000-4000-8000-000000000003',workspaceId:'10000000-0000-4000-8000-000000000004',projectId:root};
|
||||||
|
// 第一代文本节点工厂,可指定 owner,基线正文 original 便于区分本地和远端改动。
|
||||||
const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
|
const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
|
||||||
|
// root 的 A/B/F 与 child 的 C,通过共同 root 的边 E 形成加载期引用闭包。
|
||||||
const a=node('A'),b=node('B'),frame={...node('F'),componentId:'frame'},c=node('C',child),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'C'},path:null};
|
const a=node('A'),b=node('B'),frame={...node('F'),componentId:'frame'},c=node('C',child),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'C'},path:null};
|
||||||
|
// prefix 只含 root/A,full 含五元素和 child;partial engine 不能因为某对象未到就推定其不存在。
|
||||||
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],prefix=createDocument(root,[canvases[0]],[a]),full=createDocument(root,canvases,[a,b,frame,c,edge]);
|
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],prefix=createDocument(root,[canvases[0]],[a]),full=createDocument(root,canvases,[a,b,frame,c,edge]);
|
||||||
|
// 捕获源版本、loading v1 元数据和 A 的条件身份;后续协议版本在用例中显式补上证书。
|
||||||
const versions=new Map([[root,1],[child,1]]),state={version:1,sourceToken:'captured-read',declaredCanvasIds:[root,child],receivedElements:1,totalElements:5},target={id:'A',incarnation:1};
|
const versions=new Map([[root,1],[child,1]]),state={version:1,sourceToken:'captured-read',declaredCanvasIds:[root,child],receivedElements:1,totalElements:5},target={id:'A',incarnation:1};
|
||||||
|
// 建立可重开的 image、文件、leader 与假服务端;extra 可覆盖加载状态、初始文档、时钟或资源端口。
|
||||||
function fixture(initial=full){
|
function fixture(initial=full){
|
||||||
let image=null,leader,server=initial;const files=new Map();const serverVersions=new Map([...initial.canvases.keys()].map(id=>[id,1])),calls=[],receipts=new Map(),errors=[];
|
let image=null,leader,server=initial;const files=new Map();const serverVersions=new Map([...initial.canvases.keys()].map(id=>[id,1])),calls=[],receipts=new Map(),errors=[];
|
||||||
const storage={broken:false,saved:[],async claim(scope){assert.equal(leader,undefined);const held={scope,fence:1,active:true,async release(){leader=undefined;held.active=false;}};leader=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,leader);if(storage.broken)throw Error('quota');image=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},async readFile(_leader,id){return files.get(id);},files,close(){},image:()=>structuredClone(image)};
|
// 内存原子文件接口:校验 owner,broken 时在修改前失败,成功保存 image/文件和已保存快照列表。
|
||||||
|
const storage={broken:false,saved:[],
|
||||||
|
// 同一 fixture 只有一个存储 owner,release 清空 leader 并使旧 handle inactive;不模拟跨标签竞选。
|
||||||
|
async claim(scope){assert.equal(leader,undefined);const held={scope,fence:1,active:true,async release(){leader=undefined;held.active=false;}};leader=held;return held;},
|
||||||
|
// 返回 image 克隆,让重开真正走恢复解码,不复用旧引擎对象。
|
||||||
|
async recover(){return structuredClone(image);},
|
||||||
|
// 先验证 owner 和配额,再保存根/文件与删除列表;saved 记录实际提交内容,真实存储回滚不由此内存实现证明。
|
||||||
|
async commit(held,batch){assert.strictEqual(held,leader);if(storage.broken)throw Error('quota');image=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},
|
||||||
|
// 按 ID 读项目文件,供加载期导入与资源上传使用。
|
||||||
|
async readFile(_leader,id){return files.get(id);},files,
|
||||||
|
// 内存存储没有外部连接需要关闭,leader 生命周期由引擎归还。
|
||||||
|
close(){},
|
||||||
|
// 向故障注入/恢复断言交出克隆,避免检查代码意外改动当前已保存 image。
|
||||||
|
image:()=>structuredClone(image)};
|
||||||
|
// 把指定参与 canvas 的服务端内容编码为 canonical 模型;回执是局部模型,完整读取默认包含所有范围。
|
||||||
const model=(participants=[...serverVersions.keys()],complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...server.canvases.values()].filter(item=>participants.includes(item.id)),elements:[...server.elements.values()].filter(item=>participants.includes(item.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,serverVersions.get(id)])),removedCanvasIds:participants.filter(id=>!server.canvases.has(id))});
|
const model=(participants=[...serverVersions.keys()],complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...server.canvases.values()].filter(item=>participants.includes(item.id)),elements:[...server.elements.values()].filter(item=>participants.includes(item.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,serverVersions.get(id)])),removedCanvasIds:participants.filter(id=>!server.canvases.has(id))});
|
||||||
|
// GET 返回完整模型或保存回执;POST 检查 expected_versions,并按 requestId 幂等应用批次、递增参与版本。
|
||||||
const transport=async input=>{
|
const transport=async input=>{
|
||||||
if(input.method==='GET')return input.path.endsWith('/model')?{status:200,body:{model:model()}}:{status:200,body:receipts.get(input.path.split('/').at(-1))};
|
if(input.method==='GET')return input.path.endsWith('/model')?{status:200,body:{model:model()}}:{status:200,body:receipts.get(input.path.split('/').at(-1))};
|
||||||
|
// 只计文档写请求,先验证每个参与 canvas 的 ACK 基线;已有 receipt 的同 ID 请求不得重复应用 changes。
|
||||||
calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],serverVersions.get(id)??0);
|
calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],serverVersions.get(id)??0);
|
||||||
|
// 假服务先验证版本,再检查同 ID 是否已应用;只覆盖本文件的协调路径,不模拟完整生产幂等协议。
|
||||||
if(!receipts.has(input.requestId)){server=applyChanges(server,body.operations.flatMap(item=>item.changes));for(const id of participants)serverVersions.set(id,(serverVersions.get(id)??0)+1);receipts.set(input.requestId,{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(item=>item.id),model:model(participants,false)});}
|
if(!receipts.has(input.requestId)){server=applyChanges(server,body.operations.flatMap(item=>item.changes));for(const id of participants)serverVersions.set(id,(serverVersions.get(id)??0)+1);receipts.set(input.requestId,{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(item=>item.id),model:model(participants,false)});}
|
||||||
return{status:200,body:receipts.get(input.requestId)};
|
return{status:200,body:receipts.get(input.requestId)};
|
||||||
};
|
};
|
||||||
|
// 使用真实 Foundry persistence 编解码,只有底层 transport 与存储被替换为测试实现。
|
||||||
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:root,registry,transport});
|
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:root,registry,transport});
|
||||||
|
// 暴露服务端推进及版本副本供冲突注入;open 默认从 prefix/捕获版本/初级 loading 状态开始。
|
||||||
return{storage,persistence,calls,errors,server:()=>server,advance(changes){server=applyChanges(server,changes);serverVersions.set(root,serverVersions.get(root)+1);},currentVersions:()=>new Map(serverVersions),open:extra=>openProjectEngine({scope,registry,document:prefix,versions,loading:state,storage,persistence,createId:randomUUID,onError:error=>errors.push(error),...extra})};
|
return{storage,persistence,calls,errors,server:()=>server,advance(changes){server=applyChanges(server,changes);serverVersions.set(root,serverVersions.get(root)+1);},currentVersions:()=>new Map(serverVersions),open:extra=>openProjectEngine({scope,registry,document:prefix,versions,loading:state,storage,persistence,createId:randomUUID,onError:error=>errors.push(error),...extra})};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1 可对已加载 A 编辑和 undo/redo 并保存,但不冻结请求;重开/接纳前缀不覆盖本地值,full 完成后才同步并保留历史。
|
||||||
test('loaded fields edit and undo/redo durably while no request freezes; full source adds tail and sends each edit once',async()=>{
|
test('loaded fields edit and undo/redo durably while no request freezes; full source adds tail and sends each edit once',async()=>{
|
||||||
const f=fixture();let engine=await f.open();try{
|
const f=fixture();let engine=await f.open();try{
|
||||||
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}}).status,'applied');await engine.settle();
|
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}}).status,'applied');await engine.settle();
|
||||||
@@ -35,6 +68,7 @@ test('loaded fields edit and undo/redo durably while no request freezes; full so
|
|||||||
assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.server().elements.get('A').geometry.x,25);assert.ok(f.calls.length>0);assert.equal(engine.getSnapshot().history.undo.length,2);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));assert.equal(f.errors.length,0);
|
assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.server().elements.get('A').geometry.x,25);assert.ok(f.calls.length>0);assert.equal(engine.getSnapshot().history.undo.length,2);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));assert.equal(f.errors.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 不完整范围拒绝结构、资源引用、移动和危险 presentation 写;公共入口不能删 loading 标记,stageFiles/copy 也不能绕过。
|
||||||
test('incomplete scopes block structure/resources/references and untrusted marker removal without changing the root',async()=>{
|
test('incomplete scopes block structure/resources/references and untrusted marker removal without changing the root',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{
|
const f=fixture(),engine=await f.open();try{
|
||||||
for(const command of [{type:'element.remove',target},{type:'element.create',element:node('new')},{type:'element.move',target,canvasId:child},{type:'element.data',target,patch:{assetId:'asset'}},{type:'element.data',target,patch:{presentation:{scale:2}}}]){const before=engine.getSnapshot();assert.equal(engine.editor.dispatch(command).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);}
|
for(const command of [{type:'element.remove',target},{type:'element.create',element:node('new')},{type:'element.move',target,canvasId:child},{type:'element.data',target,patch:{assetId:'asset'}},{type:'element.data',target,patch:{presentation:{scale:2}}}]){const before=engine.getSnapshot();assert.equal(engine.editor.dispatch(command).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);}
|
||||||
@@ -42,6 +76,7 @@ test('incomplete scopes block structure/resources/references and untrusted marke
|
|||||||
assert.throws(()=>engine.stageFiles([{name:'x',bytes:new Blob(['x'])}],()=>{}),{code:'model_loading'});await assert.rejects(engine.imports.duplicate([target]),{code:'model_loading'});assert.equal(f.calls.length,0);
|
assert.throws(()=>engine.stageFiles([{name:'x',bytes:new Blob(['x'])}],()=>{}),{code:'model_loading'});await assert.rejects(engine.imports.duplicate([target]),{code:'model_loading'});assert.equal(f.calls.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 完整基线接纳必须先持久化才能释放全局发送 hold;配额失败仍无冻结请求,retry 保存后才发送原编辑。
|
||||||
test('full baseline must be durable before the hold releases; quota retry never sends from the partial cut',async()=>{
|
test('full baseline must be durable before the hold releases; quota retry never sends from the partial cut',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{
|
const f=fixture(),engine=await f.open();try{
|
||||||
engine.editor.dispatch({type:'element.data',target,patch:{title:'saved edit'}});await engine.settle();f.storage.broken=true;
|
engine.editor.dispatch({type:'element.data',target,patch:{title:'saved edit'}});await engine.settle();f.storage.broken=true;
|
||||||
@@ -49,6 +84,7 @@ test('full baseline must be durable before the hold releases; quota retry never
|
|||||||
f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.server().elements.get('A').data.title,'saved edit');assert.ok(f.calls.length>0);assert.notEqual(f.storage.image().formatVersion,7);
|
f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.server().elements.get('A').data.title,'saved edit');assert.ok(f.calls.length>0);assert.notEqual(f.storage.image().formatVersion,7);
|
||||||
}finally{f.storage.broken=false;await engine.close();}
|
}finally{f.storage.broken=false;await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 来源 token 不匹配或已捕获前缀改变时拒绝且 root 不变;显式 fresh 完整读取可补同版本新记录,完成后旧 accept 不得降级状态。
|
||||||
test('late or changed prefix is rejected; a fresh full read preserves local edits and same-version new records',async()=>{
|
test('late or changed prefix is rejected; a fresh full read preserves local edits and same-version new records',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{
|
const f=fixture(),engine=await f.open();try{
|
||||||
engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const before=engine.getSnapshot();
|
engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const before=engine.getSnapshot();
|
||||||
@@ -57,6 +93,7 @@ test('late or changed prefix is rejected; a fresh full read preserves local edit
|
|||||||
assert.equal((await engine.loading.complete(full,versions,{fresh:true})).status,'applied');await engine.settle();const done=engine.getSnapshot();assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.strictEqual(engine.getSnapshot(),done);
|
assert.equal((await engine.loading.complete(full,versions,{fresh:true})).status,'applied');await engine.settle();const done=engine.getSnapshot();assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.strictEqual(engine.getSnapshot(),done);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 格式 7 的恢复 image 必须携带合法 loading 标记,降级格式或删标记都拒绝,不能误当普通完整文档打开。
|
||||||
test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/history without requests',async()=>{
|
test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/history without requests',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const image=f.storage.image();assert.equal(decodeRecovery(image,registry).runtime.loading.sourceToken,state.sourceToken);
|
const f=fixture(),engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const image=f.storage.image();assert.equal(decodeRecovery(image,registry).runtime.loading.sourceToken,state.sourceToken);
|
||||||
for(const formatVersion of [1,2,3,4,5,6])assert.throws(()=>decodeRecovery({...image,formatVersion},registry),{code:'unsupported_recovery_version'});
|
for(const formatVersion of [1,2,3,4,5,6])assert.throws(()=>decodeRecovery({...image,formatVersion},registry),{code:'unsupported_recovery_version'});
|
||||||
@@ -64,6 +101,7 @@ test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fresh 全量读与本地字段冲突时保留原 partial 编辑且不发请求;通过用户审阅的 keep-local 计划协调后才结束加载。
|
||||||
test('a fresh conflicting completion keeps the original partial edit and resolves through the existing reviewed recovery path',async()=>{
|
test('a fresh conflicting completion keeps the original partial edit and resolves through the existing reviewed recovery path',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{
|
const f=fixture(),engine=await f.open();try{
|
||||||
const edit=engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();
|
const edit=engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();
|
||||||
@@ -72,22 +110,27 @@ test('a fresh conflicting completion keeps the original partial edit and resolve
|
|||||||
const session=await engine.prepareRecovery();assert.ok(session.conflict);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));assert.equal((await session.resolve(plan)).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.errors.length,0);
|
const session=await engine.prepareRecovery();assert.ok(session.conflict);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));assert.equal((await session.resolve(plan)).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.errors.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 重开虽传入 full,持久化 loading 仍需保留;显式 refresh 协调完整基线后保留原历史并同步编辑。
|
||||||
test('reopening a partial image with a complete fresh model finishes through refresh without replacing history',async()=>{
|
test('reopening a partial image with a complete fresh model finishes through refresh without replacing history',async()=>{
|
||||||
const f=fixture();let engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{title:'retained'}});await engine.settle();const history=engine.getSnapshot().history;await engine.close();engine=await f.open({document:full,loading:undefined});assert.ok(engine.loading.getState());assert.equal(engine.refresh(full,versions).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'retained');assert.deepEqual(engine.getSnapshot().history.undo,history.undo);}finally{await engine.close();}
|
const f=fixture();let engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{title:'retained'}});await engine.settle();const history=engine.getSnapshot().history;await engine.close();engine=await f.open({document:full,loading:undefined});assert.ok(engine.loading.getState());assert.equal(engine.refresh(full,versions).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'retained');assert.deepEqual(engine.getSnapshot().history.undo,history.undo);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 命令表面只改标题,恶意 feature handler 实际写声明引用;检查生成 changes 后仍以 model_loading 拒绝整次发布。
|
||||||
test('permitted command cannot hide a reference write inside a feature handler',async()=>{
|
test('permitted command cannot hide a reference write inside a feature handler',async()=>{
|
||||||
const {defineComponent,defineFeature}=await import('../../src/lib/canvas-core/index.ts');
|
const {defineComponent,defineFeature}=await import('../../src/lib/canvas-core/index.ts');
|
||||||
|
// 故意声明一个会把 content.edit 转成引用字段修改的 feature,验证安全门检查最终 changes 而非命令表面 patch。
|
||||||
const custom=defineComponent({id:'unsafe-handler',dataVersion:1,structure:'node',validate:()=>true,resourceFields:[],copy:{references:[{path:['link'],target:'element',external:'preserve'}],omit:[]},features:[defineFeature('side-effect',{'content.edit':({element})=>[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.link',before:'old',after:'new',beforePresent:true,afterPresent:true}]})]});
|
const custom=defineComponent({id:'unsafe-handler',dataVersion:1,structure:'node',validate:()=>true,resourceFields:[],copy:{references:[{path:['link'],target:'element',external:'preserve'}],omit:[]},features:[defineFeature('side-effect',{'content.edit':({element})=>[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.link',before:'old',after:'new',beforePresent:true,afterPresent:true}]})]});
|
||||||
const customRegistry=createRegistry([...builtinComponents(),custom]),record={...a,componentId:custom.id,data:{title:'A',link:'old'}},document=createDocument(root,[canvases[0]],[record]),f=fixture(),engine=await f.open({registry:customRegistry,document});
|
const customRegistry=createRegistry([...builtinComponents(),custom]),record={...a,componentId:custom.id,data:{title:'A',link:'old'}},document=createDocument(root,[canvases[0]],[record]),f=fixture(),engine=await f.open({registry:customRegistry,document});
|
||||||
try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'allowed title'}}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
|
try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'allowed title'}}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 未来 loading 解释器、错误 received 数、空声明范围或伪造 frozen 请求均在恢复校验拒绝。
|
||||||
test('future loading interpreters, changed capture counts and forged frozen requests fail closed on recovery',async()=>{
|
test('future loading interpreters, changed capture counts and forged frozen requests fail closed on recovery',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{await engine.persist();const source=f.storage.image();
|
const f=fixture(),engine=await f.open();try{await engine.persist();const source=f.storage.image();
|
||||||
for(const mutate of [image=>image.payload.runtime.loading.version=2,image=>image.payload.runtime.loading.receivedElements=99,image=>image.payload.runtime.loading.declaredCanvasIds=[],image=>image.payload.runtime.ledger.requests.push(['fake',{}])]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
for(const mutate of [image=>image.payload.runtime.loading.version=2,image=>image.payload.runtime.loading.receivedElements=99,image=>image.payload.runtime.loading.declaredCanvasIds=[],image=>image.payload.runtime.ledger.requests.push(['fake',{}])]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 新完整读带远端标题,本地几何重放不覆盖它;之后 undo 几何也不回滚远端无关字段。
|
||||||
test('fresh full read merges unrelated remote fields and geometry undo does not roll them back',async()=>{
|
test('fresh full read merges unrelated remote fields and geometry undo does not roll them back',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{
|
const f=fixture(),engine=await f.open();try{
|
||||||
engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}});await engine.settle();f.advance([{kind:'field',target,field:'data.title',before:'A',after:'remote title',beforePresent:true,afterPresent:true}]);
|
engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}});await engine.settle();f.advance([{kind:'field',target,field:'data.title',before:'A',after:'remote title',beforePresent:true,afterPresent:true}]);
|
||||||
@@ -96,31 +139,38 @@ test('fresh full read merges unrelated remote fields and geometry undo does not
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 身份失效后编辑、历史遍历、前缀接纳和 refresh 都拒绝且保持同一 root,不发送迟到工作。
|
||||||
test('identity fencing rejects edits, history traversal and late prefix publication without changing the root',async()=>{
|
test('identity fencing rejects edits, history traversal and late prefix publication without changing the root',async()=>{
|
||||||
const f=fixture();let current=true;const engine=await f.open({isCurrentIdentity:()=>current});try{engine.editor.dispatch({type:'element.data',target,patch:{text:'saved'}});await engine.settle();current=false;const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'late'}}).code,'identity_changed');assert.equal(engine.editor.history.undo().code,'identity_changed');assert.equal(engine.loading.accept(prefix,versions,state).code,'identity_changed');assert.equal(engine.refresh(full,versions).code,'identity_changed');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
|
const f=fixture();let current=true;const engine=await f.open({isCurrentIdentity:()=>current});try{engine.editor.dispatch({type:'element.data',target,patch:{text:'saved'}});await engine.settle();current=false;const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'late'}}).code,'identity_changed');assert.equal(engine.editor.history.undo().code,'identity_changed');assert.equal(engine.loading.accept(prefix,versions,state).code,'identity_changed');assert.equal(engine.refresh(full,versions).code,'identity_changed');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 多次 loading.resume 共用一次后台完整读取;完成保留本地编辑,之后旧流不能降级,也不再次启动多余读取。
|
||||||
test('background full reads are shared, preserve edits and cannot downgrade after completion',async()=>{
|
test('background full reads are shared, preserve edits and cannot downgrade after completion',async()=>{
|
||||||
let release;const gate=new Promise(resolve=>release=resolve);const f=fixture();let reads=0;const persistence={...f.persistence,async read(){reads++;await gate;return{document:f.server(),versions:f.currentVersions()};}},engine=await f.open({persistence});
|
let release;const gate=new Promise(resolve=>release=resolve);const f=fixture();let reads=0;const persistence={...f.persistence,async read(){reads++;await gate;return{document:f.server(),versions:f.currentVersions()};}},engine=await f.open({persistence});
|
||||||
try{engine.editor.dispatch({type:'element.data',target,patch:{text:'background edit'}});await engine.persist();const first=engine.loading.resume(),second=engine.loading.resume();assert.strictEqual(first,second);while(!reads)await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,1);release();assert.equal((await first).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.text,'background edit');assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.equal((await engine.loading.resume()).status,'applied');assert.equal(reads,1);}finally{release();await engine.close();}
|
try{engine.editor.dispatch({type:'element.data',target,patch:{text:'background edit'}});await engine.persist();const first=engine.loading.resume(),second=engine.loading.resume();assert.strictEqual(first,second);while(!reads)await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,1);release();assert.equal((await first).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.text,'background edit');assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.equal((await engine.loading.resume()).status,'applied');assert.equal(reads,1);}finally{release();await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 关闭项目中止正在等待的背景源并保存 partial 编辑;下一 owner 重开仍保留内容且未发送。
|
||||||
test('project close aborts a background read and persists its partial edits for the next owner',async()=>{
|
test('project close aborts a background read and persists its partial edits for the next owner',async()=>{
|
||||||
const f=fixture();let aborted=false,started;const reading=new Promise(resolve=>started=resolve);const persistence={...f.persistence,read:signal=>new Promise((resolve,reject)=>{started();signal.addEventListener('abort',()=>{aborted=true;reject(signal.reason);},{once:true});})};let engine=await f.open({persistence});
|
const f=fixture();let aborted=false,started;const reading=new Promise(resolve=>started=resolve);const persistence={...f.persistence,read:signal=>new Promise((resolve,reject)=>{started();signal.addEventListener('abort',()=>{aborted=true;reject(signal.reason);},{once:true});})};let engine=await f.open({persistence});
|
||||||
engine.editor.dispatch({type:'element.data',target,patch:{text:'survives source close'}});await engine.persist();const rejected=assert.rejects(engine.loading.resume());await reading;await engine.close();await rejected;assert.equal(aborted,true);assert.equal(f.storage.image().formatVersion,7);engine=await f.open();try{assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'survives source close');assert.equal(f.calls.length,0);}finally{await engine.close();}
|
engine.editor.dispatch({type:'element.data',target,patch:{text:'survives source close'}});await engine.persist();const rejected=assert.rejects(engine.loading.resume());await reading;await engine.close();await rejected;assert.equal(aborted,true);assert.equal(f.storage.image().formatVersion,7);engine=await f.open();try{assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'survives source close');assert.equal(f.calls.length,0);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// Workspace 并发 openPartial 返回同一 engine;背景同步已完成后,旧前台完整读不替换 root 或制造冲突。
|
||||||
test('Workspace opens one partial owner and ignores an older foreground result after background synchronization',async()=>{
|
test('Workspace opens one partial owner and ignores an older foreground result after background synchronization',async()=>{
|
||||||
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture();const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:()=>({persistence:f.persistence})});
|
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture();const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:()=>({persistence:f.persistence})});
|
||||||
try{const [first,second]=await Promise.all([workspace.openPartial(prefix,versions,state),workspace.openPartial(prefix,versions,state)]);assert.strictEqual(first,second);first.editor.dispatch({type:'element.data',target,patch:{title:'kept background'}});await first.loading.resume();await first.settle();const before=first.getSnapshot();assert.strictEqual(await workspace.open(full,versions),first);assert.strictEqual(first.getSnapshot(),before);assert.equal(first.getSnapshot().runtime.conflicts.size,0);assert.equal(workspace.getProjects().size,1);}finally{await workspace.close();}
|
try{const [first,second]=await Promise.all([workspace.openPartial(prefix,versions,state),workspace.openPartial(prefix,versions,state)]);assert.strictEqual(first,second);first.editor.dispatch({type:'element.data',target,patch:{title:'kept background'}});await first.loading.resume();await first.settle();const before=first.getSnapshot();assert.strictEqual(await workspace.open(full,versions),first);assert.strictEqual(first.getSnapshot(),before);assert.equal(first.getSnapshot().runtime.conflicts.size,0);assert.equal(workspace.getProjects().size,1);}finally{await workspace.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 背景 offline 上报一次并保留 partial cut;身份退役后的 resume 报 identity_changed,不继续向旧观察者上报源错误。
|
||||||
test('Workspace reports a background source failure while preserving the pending partial cut and fences retired reports',async()=>{
|
test('Workspace reports a background source failure while preserving the pending partial cut and fences retired reports',async()=>{
|
||||||
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture(),reports=[];let current=true;const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>current,onError:error=>reports.push(error),adapters:()=>({persistence:{...f.persistence,async read(){throw Error('offline');}}})});
|
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture(),reports=[];let current=true;const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>current,onError:error=>reports.push(error),adapters:()=>({persistence:{...f.persistence,async read(){throw Error('offline');}}})});
|
||||||
try{const engine=await workspace.openPartial(prefix,versions,state);engine.editor.dispatch({type:'element.data',target,patch:{text:'held while offline'}});await engine.persist();await assert.rejects(workspace.resumeLoading(root),/offline/);assert.equal(reports.length,1);assert.ok(engine.loading.getState());assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);current=false;await assert.rejects(workspace.resumeLoading(root),/identity_changed/);assert.equal(reports.length,1);}finally{await workspace.close();}
|
try{const engine=await workspace.openPartial(prefix,versions,state);engine.editor.dispatch({type:'element.data',target,patch:{text:'held while offline'}});await engine.persist();await assert.rejects(workspace.resumeLoading(root),/offline/);assert.equal(reports.length,1);assert.ok(engine.loading.getState());assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);current=false;await assert.rejects(workspace.resumeLoading(root),/identity_changed/);assert.equal(reports.length,1);}finally{await workspace.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v2 计数证书可证明 child 成员完整但 root/关联边仍不完整;覆盖元数据与内容原子发布,重开保留,最终覆盖完成不虚增文档 revision。
|
||||||
test('certified scopes distinguish a complete child from an incomplete parent and publish coverage atomically',async()=>{
|
test('certified scopes distinguish a complete child from an incomplete parent and publish coverage atomically',async()=>{
|
||||||
const loading={...state,version:2,canvasElementCounts:{[root]:4,[child]:1}},f=fixture();let engine=await f.open({loading});
|
const loading={...state,version:2,canvasElementCounts:{[root]:4,[child]:1}},f=fixture();let engine=await f.open({loading});
|
||||||
try{
|
try{
|
||||||
|
// 先保存旧查询视图;后续新覆盖发布后,旧视图仍描述原来的 loaded/unloaded 状态。
|
||||||
const first=engine.editor.getQueries();assert.equal(first.lookupElement('B').status,'unloaded');assert.equal(first.lookupElement('A').status,'present');assert.equal(first.membersResult(root).complete,false);assert.equal(first.hitNodes(root,{x:50,y:40}).complete,false);
|
const first=engine.editor.getQueries();assert.equal(first.lookupElement('B').status,'unloaded');assert.equal(first.lookupElement('A').status,'present');assert.equal(first.membersResult(root).complete,false);assert.equal(first.hitNodes(root,{x:50,y:40}).complete,false);
|
||||||
const next=createDocument(root,canvases,[a,frame,c]);assert.equal(engine.loading.accept(next,versions,{...loading,receivedElements:3}).status,'applied');
|
const next=createDocument(root,canvases,[a,frame,c]);assert.equal(engine.loading.accept(next,versions,{...loading,receivedElements:3}).status,'applied');
|
||||||
const queries=engine.editor.getQueries();assert.equal(queries.membersResult(child).complete,true);assert.deepEqual(queries.membersResult(child).ids,['C']);assert.equal(queries.membersResult(root).complete,false);assert.equal(queries.childCanvasesResult(root).complete,true);assert.equal(queries.incidentResult('C').complete,false);assert.equal(queries.relations.referenceComplete,false);assert.equal(queries.coverage.projectComplete,false);assert.equal(first.lookupElement('C').status,'unloaded');
|
const queries=engine.editor.getQueries();assert.equal(queries.membersResult(child).complete,true);assert.deepEqual(queries.membersResult(child).ids,['C']);assert.equal(queries.membersResult(root).complete,false);assert.equal(queries.childCanvasesResult(root).complete,true);assert.equal(queries.incidentResult('C').complete,false);assert.equal(queries.relations.referenceComplete,false);assert.equal(queries.coverage.projectComplete,false);assert.equal(first.lookupElement('C').status,'unloaded');
|
||||||
@@ -129,20 +179,24 @@ test('certified scopes distinguish a complete child from an incomplete parent an
|
|||||||
for(const mutate of [image=>delete image.payload.runtime.loading.canvasElementCounts,image=>image.payload.runtime.loading.version=1,image=>image.payload.runtime.loading.version=99,image=>image.payload.runtime.loading.canvasElementCounts[child]=0]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
for(const mutate of [image=>delete image.payload.runtime.loading.canvasElementCounts,image=>image.payload.runtime.loading.version=1,image=>image.payload.runtime.loading.version=99,image=>image.payload.runtime.loading.canvasElementCounts[child]=0]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||||
await engine.close();engine=await f.open({loading});assert.equal(engine.editor.getQueries().membersResult(child).complete,true);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'child edit');
|
await engine.close();engine=await f.open({loading});assert.equal(engine.editor.getQueries().membersResult(child).complete,true);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'child edit');
|
||||||
assert.equal(engine.loading.accept(full,versions,{...loading,receivedElements:5}).status,'applied');const before=engine.getSnapshot(),oldQueries=engine.editor.getQueries();assert.equal(oldQueries.lookupElement('missing').status,'unloaded');assert.equal(oldQueries.membersResult(root).complete,true);
|
assert.equal(engine.loading.accept(full,versions,{...loading,receivedElements:5}).status,'applied');const before=engine.getSnapshot(),oldQueries=engine.editor.getQueries();assert.equal(oldQueries.lookupElement('missing').status,'unloaded');assert.equal(oldQueries.membersResult(root).complete,true);
|
||||||
|
// 这里仅覆盖状态完成,文档内容已齐;root 可变化,但文档 revision 不应为元数据变化而虚增。
|
||||||
const completion=await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});assert.equal(completion.status,'applied');assert.equal(completion.revision,before.revision,'coverage-only completion must not invent a document revision');assert.equal(engine.editor.getQueries().lookupElement('missing').status,'absent');assert.equal(engine.editor.getQueries().coverage.projectComplete,true);assert.equal(oldQueries.lookupElement('missing').status,'unloaded');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'child edit');assert.equal(f.storage.image().formatVersion<7,true);
|
const completion=await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});assert.equal(completion.status,'applied');assert.equal(completion.revision,before.revision,'coverage-only completion must not invent a document revision');assert.equal(engine.editor.getQueries().lookupElement('missing').status,'absent');assert.equal(engine.editor.getQueries().coverage.projectComplete,true);assert.equal(oldQueries.lookupElement('missing').status,'unloaded');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'child edit');assert.equal(f.storage.image().formatVersion<7,true);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 旧 v1 前缀即使收齐五元素也不能猜测某范围完整;同一读取中临时换成不同 count manifest 必须拒绝。
|
||||||
test('legacy prefixes do not infer scope completion; a changed count manifest cannot replace the published root',async()=>{
|
test('legacy prefixes do not infer scope completion; a changed count manifest cannot replace the published root',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{
|
const f=fixture(),engine=await f.open();try{
|
||||||
assert.equal(engine.loading.accept(full,versions,{...state,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().membersResult(child).complete,false);
|
assert.equal(engine.loading.accept(full,versions,{...state,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().membersResult(child).complete,false);
|
||||||
const before=engine.getSnapshot();assert.equal(engine.loading.accept(full,versions,{...state,version:2,canvasElementCounts:{[root]:4,[child]:1},receivedElements:5}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before);
|
const before=engine.getSnapshot();assert.equal(engine.loading.accept(full,versions,{...state,version:2,canvasElementCounts:{[root]:4,[child]:1},receivedElements:5}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 零成员 child 也须其入口依赖已发布才标记 members complete;项目仍加载中,未知 ID 仍是 unloaded。
|
||||||
test('zero-member scopes are complete only once their entry dependencies publish',async()=>{
|
test('zero-member scopes are complete only once their entry dependencies publish',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame]),loading={...state,version:2,totalElements:2,receivedElements:2,canvasElementCounts:{[root]:2,[child]:0}},engine=await f.open({document,loading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame]),loading={...state,version:2,totalElements:2,receivedElements:2,canvasElementCounts:{[root]:2,[child]:0}},engine=await f.open({document,loading});
|
||||||
try{assert.deepEqual(engine.editor.getQueries().membersResult(child),{ids:[],complete:true});assert.equal(engine.editor.getQueries().lookupElement('not-here').status,'unloaded');}finally{await engine.close();}
|
try{assert.deepEqual(engine.editor.getQueries().membersResult(child),{ids:[],complete:true});assert.equal(engine.editor.getQueries().lookupElement('not-here').status,'unloaded');}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v2 允许完整连线 owner 内改 path,即使另一个 canvas 未完成;undo/redo 和格式 8 恢复保留路径,但 owner 不完整时禁用。
|
||||||
test('v2 admits path edits in a certified connection owner while another scope is incomplete, preserving undo and v8 recovery',async()=>{
|
test('v2 admits path edits in a certified connection owner while another scope is incomplete, preserving undo and v8 recovery',async()=>{
|
||||||
const f=fixture(),loading={...state,version:2,receivedElements:5,totalElements:6,canvasElementCounts:{[root]:4,[child]:2}},engine=await f.open({document:full,loading}),path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:25},in:{x:0,y:0},out:{x:0,y:0}}]};
|
const f=fixture(),loading={...state,version:2,receivedElements:5,totalElements:6,canvasElementCounts:{[root]:4,[child]:2}},engine=await f.open({document:full,loading}),path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:25},in:{x:0,y:0},out:{x:0,y:0}}]};
|
||||||
try{
|
try{
|
||||||
@@ -154,6 +208,7 @@ test('v2 admits path edits in a certified connection owner while another scope i
|
|||||||
const other=fixture(),incomplete=await other.open({document:full,loading:{...loading,canvasElementCounts:{[root]:5,[child]:1}}});try{assert.equal(incomplete.editor.can({type:'connection.path',target:{id:'E',incarnation:1},path}).enabled,false);}finally{await incomplete.close();}
|
const other=fixture(),incomplete=await other.open({document:full,loading:{...loading,canvasElementCounts:{[root]:5,[child]:1}}});try{assert.equal(incomplete.editor.can({type:'connection.path',target:{id:'E',incarnation:1},path}).enabled,false);}finally{await incomplete.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v3 完整 child 的修改可先同步,root 编辑继续等待;原流捕获版本和新 ACK 分开,旧后缀/最终完成不覆盖已确认 child 编辑。
|
||||||
test('v3 sends a certified child before the parent tail, retains its ACK through old chunks and completes the original source',async()=>{
|
test('v3 sends a certified child before the parent tail, retains its ACK through old chunks and completes the original source',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading});
|
||||||
try{
|
try{
|
||||||
@@ -165,6 +220,7 @@ test('v3 sends a certified child before the parent tail, retains its ACK through
|
|||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'parent waits');assert.equal(f.server().elements.get('C').data.text,'original');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'A');
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'parent waits');assert.equal(f.server().elements.get('C').data.text,'original');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'A');
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v3 请求丢 ACK 时 fresh 完整接纳先等回执;重开查询原请求而不新发,保留 body/key,并拒绝篡改 loadingRead/旧流内容。
|
||||||
test('v3 unknown request reopens with original body/key, queries its receipt and rejects source tampering',async()=>{
|
test('v3 unknown request reopens with original body/key, queries its receipt and rejects source tampering',async()=>{
|
||||||
let time=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},clock={now:()=>time,setTimer:()=>0,clearTimer(){}},persistence={...f.persistence,async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}};
|
let time=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},clock={now:()=>time,setTimer:()=>0,clearTimer(){}},persistence={...f.persistence,async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}};
|
||||||
let engine=await f.open({document,loading,persistence,clock});try{
|
let engine=await f.open({document,loading,persistence,clock});try{
|
||||||
@@ -177,28 +233,35 @@ test('v3 unknown request reopens with original body/key, queries its receipt and
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 背景完整读取在网络 lane 外等待未知回执;retry 先解决原请求再读 fresh 模型,不能占住网络位置阻止查询。
|
||||||
test('background completion waits outside the network lane for a v3 unknown receipt, then reads the fresh full model',async()=>{
|
test('background completion waits outside the network lane for a v3 unknown receipt, then reads the fresh full model',async()=>{
|
||||||
let time=0,reads=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},clock={now:()=>time,setTimer:()=>0,clearTimer(){}},persistence={...f.persistence,async read(signal){reads++;return f.persistence.read(signal);},async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}},engine=await f.open({document,loading,persistence,clock});
|
let time=0,reads=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},clock={now:()=>time,setTimer:()=>0,clearTimer(){}},persistence={...f.persistence,async read(signal){reads++;return f.persistence.read(signal);},async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}},engine=await f.open({document,loading,persistence,clock});
|
||||||
try{engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'wait for original receipt'}});await engine.settle();const completion=engine.loading.resume();await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,0);time=1000;await engine.retry();assert.equal((await completion).status,'applied');await engine.settle();assert.equal(reads,1);assert.equal(f.calls.length,1);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'wait for original receipt');assert.equal(engine.loading.getState(),undefined);}finally{await engine.close();}
|
try{engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'wait for original receipt'}});await engine.settle();const completion=engine.loading.resume();await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,0);time=1000;await engine.retry();assert.equal((await completion).status,'applied');await engine.settle();assert.equal(reads,1);assert.equal(f.calls.length,1);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'wait for original receipt');assert.equal(engine.loading.getState(),undefined);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fresh 背景读期间 hold 新网络准入,但仍保留当时提交的本地编辑;读完成后重放并只发送一次。
|
||||||
test('a fresh v3 background read fences new admissions while preserving edits made during the read',async()=>{
|
test('a fresh v3 background read fences new admissions while preserving edits made during the read',async()=>{
|
||||||
let release,started;const gate=new Promise(resolve=>release=resolve),reading=new Promise(resolve=>started=resolve),f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading,persistence:{...f.persistence,async read(signal){started();await gate;return f.persistence.read(signal);}}});
|
let release,started;const gate=new Promise(resolve=>release=resolve),reading=new Promise(resolve=>started=resolve),f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading,persistence:{...f.persistence,async read(signal){started();await gate;return f.persistence.read(signal);}}});
|
||||||
try{const completion=engine.loading.resume();await reading;engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'edited during fresh read'}});await engine.settle();assert.equal(f.calls.length,0);release();assert.equal((await completion).status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'edited during fresh read');assert.equal(f.calls.length,1);}finally{release();await engine.close();}
|
try{const completion=engine.loading.resume();await reading;engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'edited during fresh read'}});await engine.settle();assert.equal(f.calls.length,0);release();assert.equal((await completion).status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'edited during fresh read');assert.equal(f.calls.length,1);}finally{release();await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v3 范围虽完整,源及编辑尚未落盘时仍不能 freeze;配额恢复后 retry 唤醒同一逻辑操作。
|
||||||
test('a v3 certified owner cannot freeze until its source and edit are durably saved; retry wakes the same logical intent',async()=>{
|
test('a v3 certified owner cannot freeze until its source and edit are durably saved; retry wakes the same logical intent',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading});
|
||||||
try{f.storage.broken=true;const edit=engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'durability first'}});await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setImmediate(resolve));assert.equal(engine.getSnapshot().runtime.ledger.requests.size,0);assert.equal(f.calls.length,0);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.calls.length,1);assert.equal(f.server().elements.get('C').data.text,'durability first');assert.equal(engine.getSnapshot().runtime.ledger.operations.get(edit.operationIds[0]).phase,'resolved');}finally{f.storage.broken=false;await engine.close();}
|
try{f.storage.broken=true;const edit=engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'durability first'}});await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setImmediate(resolve));assert.equal(engine.getSnapshot().runtime.ledger.requests.size,0);assert.equal(f.calls.length,0);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.calls.length,1);assert.equal(f.server().elements.get('C').data.text,'durability first');assert.equal(engine.getSnapshot().runtime.ledger.operations.get(edit.operationIds[0]).phase,'resolved');}finally{f.storage.broken=false;await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 关闭回执等待取消订阅、保存未知请求,不额外发完整读取;格式 9 重开仍带原请求证据。
|
||||||
test('closing a v3 background receipt wait unsubscribes and saves the unknown request without issuing a fresh read',async()=>{
|
test('closing a v3 background receipt wait unsubscribes and saves the unknown request without issuing a fresh read',async()=>{
|
||||||
let reads=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading,clock:{now:()=>0,setTimer:()=>0,clearTimer(){}},persistence:{...f.persistence,async read(signal){reads++;return f.persistence.read(signal);},async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}}});
|
let reads=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading,clock:{now:()=>0,setTimer:()=>0,clearTimer(){}},persistence:{...f.persistence,async read(signal){reads++;return f.persistence.read(signal);},async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}}});
|
||||||
engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'retain on close'}});await engine.settle();const waiting=assert.rejects(engine.loading.resume());await new Promise(resolve=>setImmediate(resolve));await engine.close();await waiting;assert.equal(reads,0);assert.equal(f.calls.length,1);assert.equal(f.storage.image().formatVersion,9);assert.equal(decodeRecovery(f.storage.image(),registry).runtime.ledger.requests.size,1);
|
engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'retain on close'}});await engine.settle();const waiting=assert.rejects(engine.loading.resume());await new Promise(resolve=>setImmediate(resolve));await engine.close();await waiting;assert.equal(reads,0);assert.equal(f.calls.length,1);assert.equal(f.storage.image().formatVersion,9);assert.equal(decodeRecovery(f.storage.image(),registry).runtime.ledger.requests.size,1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 捕获源全量 ID→owner 清单,区分尚未加载的源 ID 与确实未占用的新 ID。
|
||||||
const identities=Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId]));
|
const identities=Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId]));
|
||||||
|
// loading v4 在范围计数外携带身份清单,允许完整 owner 内的安全结构修改。
|
||||||
const structuralLoading={...state,version:4,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1},elementOwners:identities};
|
const structuralLoading={...state,version:4,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1},elementOwners:identities};
|
||||||
|
// v4 身份清单证明 fresh ID 未占用,允许完整 child 内创建/确认/undo/redo;未加载 B 的 ID 仍禁复用,旧流完成保留第二代新元素。
|
||||||
test('v4 creates, acknowledges, undoes and restores a fresh element in a complete owner before an unrelated tail',async()=>{
|
test('v4 creates, acknowledges, undoes and restores a fresh element in a complete owner before an unrelated tail',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('fresh',child);
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('fresh',child);
|
||||||
try{
|
try{
|
||||||
@@ -208,6 +271,7 @@ test('v4 creates, acknowledges, undoes and restores a fresh element in a complet
|
|||||||
assert.equal(engine.loading.accept(full,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('fresh').incarnation,2);assert.equal(engine.getSnapshot().runtime.versions.get(child),4);assert.equal(f.calls.length,3);assert.equal(f.errors.length,0);
|
assert.equal(engine.loading.accept(full,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('fresh').incarnation,2);assert.equal(engine.getSnapshot().runtime.versions.get(child),4);assert.equal(f.calls.length,3);assert.equal(f.errors.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v4 frame/canvas 一次事务是一组历史,新 canvas 有独立版本;重开 undo/redo 后原流不删除新增范围或回退其版本。
|
||||||
test('v4 frame/canvas creation is one atomic history unit and new scope versions recover independently of the old stream',async()=>{
|
test('v4 frame/canvas creation is one atomic history unit and new scope versions recover independently of the old stream',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),newCanvas='10000000-0000-4000-8000-000000000009',newFrame={...node('new-frame',child),componentId:'frame'};let engine=await f.open({document,loading:structuralLoading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),newCanvas='10000000-0000-4000-8000-000000000009',newFrame={...node('new-frame',child),componentId:'frame'};let engine=await f.open({document,loading:structuralLoading});
|
||||||
try{
|
try{
|
||||||
@@ -217,6 +281,7 @@ test('v4 frame/canvas creation is one atomic history unit and new scope versions
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v4 原对象删除先等捕获引用闭包完整;本地新生节点/连线可成组创建删除,最终原流到齐后删除 A 也包含 E。
|
||||||
test('v4 defers original deletions until the captured reference closure is loaded, while born links and nodes can be removed atomically',async()=>{
|
test('v4 defers original deletions until the captured reference closure is loaded, while born links and nodes can be removed atomically',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading});
|
||||||
try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.remove',target}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);
|
try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.remove',target}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);
|
||||||
@@ -224,32 +289,40 @@ test('v4 defers original deletions until the captured reference closure is loade
|
|||||||
assert.equal(engine.loading.accept(full,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.remove',target}).status,'applied');await engine.settle();assert.equal(f.server().elements.has('A'),false);assert.equal(f.server().elements.has('E'),false);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has('A'),false);assert.equal(f.errors.length,0);
|
assert.equal(engine.loading.accept(full,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.remove',target}).status,'applied');await engine.settle();assert.equal(f.server().elements.has('A'),false);assert.equal(f.server().elements.has('E'),false);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has('A'),false);assert.equal(f.errors.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v4 普通改名保留源 AI 元数据;缺失/错归属身份清单及旧 image 格式不能伪装恢复。
|
||||||
test('v4 identity proof preserves source AI metadata during ordinary edits and rejects forged membership recovery',async()=>{
|
test('v4 identity proof preserves source AI metadata during ordinary edits and rejects forged membership recovery',async()=>{
|
||||||
const old={...c,data:{...c.data,ai:{enabled:true}}},f=fixture(createDocument(root,canvases,[a,b,frame,old,edge])),document=createDocument(root,canvases,[a,frame,old]),engine=await f.open({document,loading:structuralLoading});
|
const old={...c,data:{...c.data,ai:{enabled:true}}},f=fixture(createDocument(root,canvases,[a,b,frame,old,edge])),document=createDocument(root,canvases,[a,frame,old]),engine=await f.open({document,loading:structuralLoading});
|
||||||
try{assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{title:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.errors.length,0);assert.equal(engine.getSnapshot().document.elements.get('C').data.ai.enabled,true);const saved=f.storage.image();decodeRecovery(saved,registry);for(const mutate of [image=>delete image.payload.runtime.loading.elementOwners,image=>image.payload.runtime.loading.elementOwners.C=root,image=>image.formatVersion=9]){const broken=structuredClone(saved);mutate(broken);assert.throws(()=>decodeRecovery(broken,registry));}}finally{await engine.close();}
|
try{assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{title:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.errors.length,0);assert.equal(engine.getSnapshot().document.elements.get('C').data.ai.enabled,true);const saved=f.storage.image();decodeRecovery(saved,registry);for(const mutate of [image=>delete image.payload.runtime.loading.elementOwners,image=>image.payload.runtime.loading.elementOwners.C=root,image=>image.formatVersion=9]){const broken=structuredClone(saved);mutate(broken);assert.throws(()=>decodeRecovery(broken,registry));}}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v4 完全未发送的创建/删除可抵消且零请求;undo 恢复同 ID 的第二代,后续旧流不使它回到旧生命周期。
|
||||||
test('v4 elides an unsent birth/deletion and restores the same ID with a fresh lifecycle before the source tail',async()=>{
|
test('v4 elides an unsent birth/deletion and restores the same ID with a fresh lifecycle before the source tail',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('unsent-birth',child);
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('unsent-birth',child);
|
||||||
try{assert.equal(engine.editor.dispatch({type:'element.create',element:created}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:created.id,incarnation:1}}).status,'applied');await engine.settle();assert.equal(f.calls.length,0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).incarnation,2);decodeRecovery(f.storage.image(),registry);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).incarnation,2);assert.equal(f.calls.length,1);}finally{await engine.close();}
|
try{assert.equal(engine.editor.dispatch({type:'element.create',element:created}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:created.id,incarnation:1}}).status,'applied');await engine.settle();assert.equal(f.calls.length,0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).incarnation,2);decodeRecovery(f.storage.image(),registry);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).incarnation,2);assert.equal(f.calls.length,1);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v4 新生节点换 owner 要求源/目标范围均可用;禁止移入未完成 root,允许 child/newCanvas 间移动并保留 undo。
|
||||||
test('v4 born-node ownership changes require both scopes and preserve their history while another source owner is incomplete',async()=>{
|
test('v4 born-node ownership changes require both scopes and preserve their history while another source owner is incomplete',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),newCanvas='10000000-0000-4000-8000-000000000019',newFrame={...node('move-frame',child),componentId:'frame'},created=node('moving-birth',newCanvas);
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),newCanvas='10000000-0000-4000-8000-000000000019',newFrame={...node('move-frame',child),componentId:'frame'},created=node('moving-birth',newCanvas);
|
||||||
try{assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:newFrame});tx.dispatch({type:'canvas.create',canvas:{id:newCanvas,parent:{canvasId:child,entryElementId:newFrame.id}}});tx.dispatch({type:'element.create',element:created});}).status,'applied');await engine.settle();assert.equal(engine.editor.can({type:'element.move',target:{id:created.id,incarnation:1},canvasId:root}).enabled,false);assert.equal(engine.editor.dispatch({type:'element.move',target:{id:created.id,incarnation:1},canvasId:child}).status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).ownerCanvasId,child);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).ownerCanvasId,newCanvas);assert.equal(engine.loading.getState().version,4);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
try{assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:newFrame});tx.dispatch({type:'canvas.create',canvas:{id:newCanvas,parent:{canvasId:child,entryElementId:newFrame.id}}});tx.dispatch({type:'element.create',element:created});}).status,'applied');await engine.settle();assert.equal(engine.editor.can({type:'element.move',target:{id:created.id,incarnation:1},canvasId:root}).enabled,false);assert.equal(engine.editor.dispatch({type:'element.move',target:{id:created.id,incarnation:1},canvasId:child}).status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).ownerCanvasId,child);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).ownerCanvasId,newCanvas);assert.equal(engine.loading.getState().version,4);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 源清单已声明 A 但它尚未加载时,即使存在第一代墓碑仍应是 unloaded;加载第二代后才能条件删除并变 absent。
|
||||||
test('v4 lookup does not confuse an unloaded restored element with its older tombstone',async()=>{
|
test('v4 lookup does not confuse an unloaded restored element with its older tombstone',async()=>{
|
||||||
const restored={...a,incarnation:2},dead=new Map([['A',1]]),server=createDocument(root,canvases,[restored,b,frame,c,edge],dead),f=fixture(server),document=createDocument(root,canvases,[frame,c],dead),engine=await f.open({document,loading:{...structuralLoading,receivedElements:2}});
|
const restored={...a,incarnation:2},dead=new Map([['A',1]]),server=createDocument(root,canvases,[restored,b,frame,c,edge],dead),f=fixture(server),document=createDocument(root,canvases,[frame,c],dead),engine=await f.open({document,loading:{...structuralLoading,receivedElements:2}});
|
||||||
try{assert.equal(engine.editor.getQueries().lookupElement('A').status,'unloaded');assert.equal(engine.editor.getQueries().lookupElement('not-in-capture').status,'absent');assert.equal(engine.loading.accept(server,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().lookupElement('A').element.incarnation,2);assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'A',incarnation:2}}).status,'applied');await engine.settle();assert.equal(engine.editor.getQueries().lookupElement('A').status,'absent');}finally{await engine.close();}
|
try{assert.equal(engine.editor.getQueries().lookupElement('A').status,'unloaded');assert.equal(engine.editor.getQueries().lookupElement('not-in-capture').status,'absent');assert.equal(engine.loading.accept(server,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().lookupElement('A').element.incarnation,2);assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'A',incarnation:2}}).status,'applied');await engine.settle();assert.equal(engine.editor.getQueries().lookupElement('A').status,'absent');}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fresh 完整读可包含已确认新生对象,但不能在版本未变的不完整源范围凭空加入成员;正确后台读取保留新对象。
|
||||||
test('v4 fresh completion accepts acknowledged births but rejects an invented member in an unchanged incomplete source scope',async()=>{
|
test('v4 fresh completion accepts acknowledged births but rejects an invented member in an unchanged incomplete source scope',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('fresh-before-background',child);
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('fresh-before-background',child);
|
||||||
try{engine.editor.dispatch({type:'element.create',element:created});await engine.settle();const malformed=createDocument(root,canvases,[...f.server().elements.values(),node('invented')]);assert.equal((await engine.loading.complete(malformed,f.currentVersions(),{fresh:true})).code,'canonical_version_mismatch');assert.equal(engine.getSnapshot().document.elements.has(created.id),true);assert.equal((await engine.loading.resume()).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(created.id).incarnation,1);assert.equal(engine.getSnapshot().runtime.loadingRead,undefined);assert.equal(f.calls.length,1);}finally{await engine.close();}
|
try{engine.editor.dispatch({type:'element.create',element:created});await engine.settle();const malformed=createDocument(root,canvases,[...f.server().elements.values(),node('invented')]);assert.equal((await engine.loading.complete(malformed,f.currentVersions(),{fresh:true})).code,'canonical_version_mismatch');assert.equal(engine.getSnapshot().document.elements.has(created.id),true);assert.equal((await engine.loading.resume()).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(created.id).incarnation,1);assert.equal(engine.getSnapshot().runtime.loadingRead,undefined);assert.equal(f.calls.length,1);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// loading v5 再附源 canvas 树,让导入/复制能校验完整子树及其上级入边范围。
|
||||||
const jobLoading={...structuralLoading,version:5,sourceCanvases:canvases};
|
const jobLoading={...structuralLoading,version:5,sourceCanvases:canvases};
|
||||||
|
// 把原始元素封装为 native 导入行,测试任务准入与目标 owner 映射。
|
||||||
const ndjson=records=>new Blob(records.map(record=>JSON.stringify({kind:'element',key:record.id,record})+'\n'));
|
const ndjson=records=>new Blob(records.map(record=>JSON.stringify({kind:'element',key:record.id,record})+'\n'));
|
||||||
|
// v5 只允许导入到完整 child,root 未完整时准入就拒绝;合法任务在其他尾部仍加载时可发布/同步并保存 loadingAdmission。
|
||||||
test('v5 native import publishes and synchronizes into a complete owner while another captured owner remains incomplete',async()=>{
|
test('v5 native import publishes and synchronizes into a complete owner while another captured owner remains incomplete',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:jobLoading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:jobLoading});
|
||||||
try{assert.equal(engine.imports.canStart(root).enabled,false);assert.equal(engine.imports.canStart(child).enabled,true);assert.throws(()=>engine.imports.startNdjson(ndjson([node('source')]),root),{code:'loading_scope_incomplete'});assert.equal(engine.imports.getJobs().size,0);assert.equal(f.storage.files.size,0);
|
try{assert.equal(engine.imports.canStart(root).enabled,false);assert.equal(engine.imports.canStart(child).enabled,true);assert.throws(()=>engine.imports.startNdjson(ndjson([node('source')]),root),{code:'loading_scope_incomplete'});assert.equal(engine.imports.getJobs().size,0);assert.equal(f.storage.files.size,0);
|
||||||
@@ -257,10 +330,12 @@ test('v5 native import publishes and synchronizes into a complete owner while an
|
|||||||
assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(mapped).data.text,'hello');assert.equal(f.errors.length,0);
|
assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(mapped).data.text,'hello');assert.equal(f.errors.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v5 复制涉及的上级范围或声明子分区尚未完整,canDuplicate 和实际 duplicate 都拒绝,不能先分配任务/源文件。
|
||||||
test('v5 copy rejects an incomplete ancestor or a missing declared child before allocating task bytes',async()=>{
|
test('v5 copy rejects an incomplete ancestor or a missing declared child before allocating task bytes',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:jobLoading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:jobLoading});
|
||||||
try{const before=engine.getSnapshot(),selection=[{id:'C',incarnation:1}];assert.equal(engine.imports.canDuplicate(selection,{viewCanvasId:child}).enabled,false);await assert.rejects(engine.imports.duplicate(selection,{viewCanvasId:child}),{code:'loading_scope_incomplete'});assert.strictEqual(engine.getSnapshot(),before);assert.equal(engine.imports.getJobs().size,0);assert.equal(f.storage.files.size,0);}finally{await engine.close();}
|
try{const before=engine.getSnapshot(),selection=[{id:'C',incarnation:1}];assert.equal(engine.imports.canDuplicate(selection,{viewCanvasId:child}).enabled,false);await assert.rejects(engine.imports.duplicate(selection,{viewCanvasId:child}),{code:'loading_scope_incomplete'});assert.strictEqual(engine.getSnapshot(),before);assert.equal(engine.imports.getJobs().size,0);assert.equal(f.storage.files.size,0);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v5 无关第三 canvas 可继续加载,只要被复制子树及上级入边范围闭合;暂停/重开保留去重映射与 C 的单副本,不吸入未加载 V。
|
||||||
test('v5 deep copy includes upper incoming edges and a closed subtree while unrelated canvas contents are still loading',async()=>{
|
test('v5 deep copy includes upper incoming edges and a closed subtree while unrelated canvas contents are still loading',async()=>{
|
||||||
const other='10000000-0000-4000-8000-000000000029',g={...node('G'),componentId:'frame'},incoming={...edge,source:{elementId:'A'},target:{elementId:'F'}},topology=[...canvases,{id:other,parent:{canvasId:root,entryElementId:'G'}}],server=createDocument(root,topology,[a,frame,g,incoming,c,node('U',other),node('V',other)]),document=createDocument(root,topology,[a,frame,g,incoming,c,node('U',other)]),f=fixture(server),serverVersions=new Map([[root,1],[child,1],[other,1]]),loading={version:5,sourceToken:'closed-copy',declaredCanvasIds:[root,child,other],sourceCanvases:topology,receivedElements:6,totalElements:7,canvasElementCounts:{[root]:4,[child]:1,[other]:2},elementOwners:Object.fromEntries([...server.elements.values()].map(element=>[element.id,element.ownerCanvasId]))};
|
const other='10000000-0000-4000-8000-000000000029',g={...node('G'),componentId:'frame'},incoming={...edge,source:{elementId:'A'},target:{elementId:'F'}},topology=[...canvases,{id:other,parent:{canvasId:root,entryElementId:'G'}}],server=createDocument(root,topology,[a,frame,g,incoming,c,node('U',other),node('V',other)]),document=createDocument(root,topology,[a,frame,g,incoming,c,node('U',other)]),f=fixture(server),serverVersions=new Map([[root,1],[child,1],[other,1]]),loading={version:5,sourceToken:'closed-copy',declaredCanvasIds:[root,child,other],sourceCanvases:topology,receivedElements:6,totalElements:7,canvasElementCounts:{[root]:4,[child]:1,[other]:2},elementOwners:Object.fromEntries([...server.elements.values()].map(element=>[element.id,element.ownerCanvasId]))};
|
||||||
// The fixture's initial version vector follows the server's active scopes.
|
// The fixture's initial version vector follows the server's active scopes.
|
||||||
@@ -269,6 +344,7 @@ test('v5 deep copy includes upper incoming edges and a closed subtree while unre
|
|||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v5 paused 导入源重开仍用原 ID;loadingAdmission/token/范围/序号必须可验证,完成后 undo 的删除不会被源 full 重新复活。
|
||||||
test('v5 paused native source survives reopen, resumes with stable IDs, and undo does not revive after completion',async()=>{
|
test('v5 paused native source survives reopen, resumes with stable IDs, and undo does not revive after completion',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]);let engine=await f.open({document,loading:jobLoading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]);let engine=await f.open({document,loading:jobLoading});
|
||||||
try{const id=engine.imports.startNdjson(ndjson([node('paused-source','$target')]),child);await engine.imports.pause(id);await engine.settle();const paused=engine.imports.getJobs().get(id);assert.equal(paused.status,'paused');assert.equal(paused.committedSourceKeys.length,0);assert.equal(f.calls.length,0);assert.equal(f.storage.files.size,1);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.imports[0][1].source.loadingAdmission.sourceToken='other-cut',image=>image.payload.runtime.imports[0][1].source.loadingAdmission.requiredCanvasIds=[root],image=>image.payload.runtime.imports[0][1].source.loadingAdmission.operationSequence=999,image=>delete image.payload.runtime.loading.sourceCanvases,image=>image.formatVersion=10]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
try{const id=engine.imports.startNdjson(ndjson([node('paused-source','$target')]),child);await engine.imports.pause(id);await engine.settle();const paused=engine.imports.getJobs().get(id);assert.equal(paused.status,'paused');assert.equal(paused.committedSourceKeys.length,0);assert.equal(f.calls.length,0);assert.equal(f.storage.files.size,1);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.imports[0][1].source.loadingAdmission.sourceToken='other-cut',image=>image.payload.runtime.imports[0][1].source.loadingAdmission.requiredCanvasIds=[root],image=>image.payload.runtime.imports[0][1].source.loadingAdmission.operationSequence=999,image=>delete image.payload.runtime.loading.sourceCanvases,image=>image.formatVersion=10]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||||
@@ -276,37 +352,56 @@ test('v5 paused native source survives reopen, resumes with stable IDs, and undo
|
|||||||
await engine.close();engine=await f.open({document,loading:jobLoading});assert.equal(engine.imports.getJobs().get(id).stableIds['element:paused-source'],mapped);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has(mapped),false);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(mapped),false);assert.equal(f.errors.length,0);
|
await engine.close();engine=await f.open({document,loading:jobLoading});assert.equal(engine.imports.getJobs().get(id).stableIds['element:paused-source'],mapped);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has(mapped),false);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(mapped),false);assert.equal(f.errors.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v5 cancelled job 重开/resume 仍取消,源 full 完成只补原始五元素,不生成被取消任务的内容。
|
||||||
test('v5 cancelled native job stays cancelled through reopening and final source completion',async()=>{
|
test('v5 cancelled native job stays cancelled through reopening and final source completion',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]);let engine=await f.open({document,loading:jobLoading});
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]);let engine=await f.open({document,loading:jobLoading});
|
||||||
try{const id=engine.imports.startNdjson(ndjson([node('cancelled-source','$target')]),child);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open({document,loading:jobLoading});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(f.calls.length,0);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
try{const id=engine.imports.startNdjson(ndjson([node('cancelled-source','$target')]),child);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open({document,loading:jobLoading});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(f.calls.length,0);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v5 已知入口但 child 尚未发布时,不可把它解释为空分区并允许深复制,任务表保持空。
|
||||||
test('v5 copy cannot interpret a declared but unpublished child as an empty partition',async()=>{
|
test('v5 copy cannot interpret a declared but unpublished child as an empty partition',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,[canvases[0]],[a,b,frame]),loading={...jobLoading,receivedElements:3,canvasElementCounts:{[root]:3,[child]:2},elementOwners:{A:root,B:root,F:root,C:child,E:child}};
|
const f=fixture(),document=createDocument(root,[canvases[0]],[a,b,frame]),loading={...jobLoading,receivedElements:3,canvasElementCounts:{[root]:3,[child]:2},elementOwners:{A:root,B:root,F:root,C:child,E:child}};
|
||||||
const engine=await f.open({document,loading});try{assert.equal(engine.editor.getQueries().membersResult(root).complete,true);assert.equal(engine.getSnapshot().document.canvases.has(child),false);assert.equal(engine.imports.canDuplicate([{id:'F',incarnation:1}],{viewCanvasId:root}).enabled,false);await assert.rejects(engine.imports.duplicate([{id:'F',incarnation:1}],{viewCanvasId:root}),{code:'loading_scope_incomplete'});assert.equal(engine.imports.getJobs().size,0);}finally{await engine.close();}
|
const engine=await f.open({document,loading});try{assert.equal(engine.editor.getQueries().membersResult(root).complete,true);assert.equal(engine.getSnapshot().document.canvases.has(child),false);assert.equal(engine.imports.canDuplicate([{id:'F',incarnation:1}],{viewCanvasId:root}).enabled,false);await assert.rejects(engine.imports.duplicate([{id:'F',incarnation:1}],{viewCanvasId:root}),{code:'loading_scope_incomplete'});assert.equal(engine.imports.getJobs().size,0);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 动作可用性先检查目标范围、选择非空/身份新鲜度及策略;不可对 UI 声称可执行而到提交才发现明显缺项。
|
||||||
test('import availability rejects stale selection, missing target and policy denial before offering an action',async()=>{
|
test('import availability rejects stale selection, missing target and policy denial before offering an action',async()=>{
|
||||||
const f=fixture();let engine=await f.open({document:full,loading:undefined});try{assert.equal(engine.imports.canStart('missing').code,'canvas_not_loaded');assert.equal(engine.imports.canDuplicate([],{viewCanvasId:root}).code,'empty_copy_selection');assert.equal(engine.imports.canDuplicate([{id:'A',incarnation:2}]).code,'stale_incarnation');}finally{await engine.close();}
|
const f=fixture();let engine=await f.open({document:full,loading:undefined});try{assert.equal(engine.imports.canStart('missing').code,'canvas_not_loaded');assert.equal(engine.imports.canDuplicate([],{viewCanvasId:root}).code,'empty_copy_selection');assert.equal(engine.imports.canDuplicate([{id:'A',incarnation:2}]).code,'stale_incarnation');}finally{await engine.close();}
|
||||||
engine=await f.open({document:full,loading:undefined,policy:()=>({enabled:false,code:'permission_denied'})});try{assert.equal(engine.imports.canStart(root).code,'permission_denied');assert.equal(engine.imports.canDuplicate([target]).code,'permission_denied');}finally{await engine.close();}
|
engine=await f.open({document:full,loading:undefined,policy:()=>({enabled:false,code:'permission_denied'})});try{assert.equal(engine.imports.canStart(root).code,'permission_denied');assert.equal(engine.imports.canDuplicate([target]).code,'permission_denied');}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// loading v6 为完整 owner 中的文件暂存/上传和已有共享资源接纳增加明确准入证据。
|
||||||
const assetLoading={...jobLoading,version:6};
|
const assetLoading={...jobLoading,version:6};
|
||||||
function assetPort(f){const uploads=[],receipts=new Map();let lose=false;const assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,bytes){const value=JSON.parse(request.body);assert.equal(f.storage.files.get(value.resource_id).size,bytes.size);assert.ok(f.storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.phase==='dispatch-marked'));uploads.push(request);const stored={id:value.resource_id,url:assets.sourceURL(value.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);if(lose)throw Error('lost upload ACK');return{outcome:'applied',canonical:stored};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:receipts.get(request.id)}:{outcome:'unresolved'};},accept:(_request,status)=>status.canonical};return{assets,uploads,lose(){lose=true;}};}
|
// 可记录上传和丢 ACK 的资源端口;上传前核对原 Blob 与 dispatch 证据已保存,成功资产可按原请求查询。
|
||||||
|
function assetPort(f){const uploads=[],receipts=new Map();let lose=false;const assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,
|
||||||
|
// 上传先核对原 Blob 大小及持久化 dispatch 标记;保存资产回执后 lose 开关可抛错模拟服务成功、客户端丢 ACK。
|
||||||
|
async upload(request,bytes){const value=JSON.parse(request.body);assert.equal(f.storage.files.get(value.resource_id).size,bytes.size);assert.ok(f.storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.phase==='dispatch-marked'));uploads.push(request);const stored={id:value.resource_id,url:assets.sourceURL(value.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);if(lose)throw Error('lost upload ACK');return{outcome:'applied',canonical:stored};},
|
||||||
|
// 按原 requestId 查保存的上传结果;未知保持 unresolved,不能用新上传掩盖未知请求。
|
||||||
|
async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:receipts.get(request.id)}:{outcome:'unresolved'};},
|
||||||
|
// 把 canonical 元数据交给引擎做身份验证与依赖解锁,此处不模拟服务端二进制上传本身。
|
||||||
|
accept:(_request,status)=>status.canonical};return{assets,uploads,
|
||||||
|
// 从此让上传回执丢失,用于重开后必须走查询且不重新传字节的场景。
|
||||||
|
lose(){lose=true;}};}
|
||||||
|
// 在 stageFiles 的同步事务内创建图片,返回资源句柄与提交结果,owner 可切换以验证范围拒绝。
|
||||||
function stageLoadingImage(engine,owner=child,id='partial-image'){let ref;const result=engine.stageFiles([{name:'picture.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([resource],tx)=>{ref=resource;tx.dispatch({type:'element.create',element:{...node(id,owner),componentId:'image',data:{title:'Picture',text:'',assetId:ref.id,src:ref.src}}});});return{result,ref};}
|
function stageLoadingImage(engine,owner=child,id='partial-image'){let ref;const result=engine.stageFiles([{name:'picture.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([resource],tx)=>{ref=resource;tx.dispatch({type:'element.create',element:{...node(id,owner),componentId:'image',data:{title:'Picture',text:'',assetId:ref.id,src:ref.src}}});});return{result,ref};}
|
||||||
|
// v6 图片立即显示,完整 child 允许持久化后上传并同步,未完成 root 拒绝;恢复校验准入证据,undo/redo 不重复上传,旧源完成保留资源。
|
||||||
test('v6 displays, persists and uploads a scoped image before another owner tail; undo/redo and old completion preserve its resource',async()=>{
|
test('v6 displays, persists and uploads a scoped image before another owner tail; undo/redo and old completion preserve its resource',async()=>{
|
||||||
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{assert.equal(engine.canStageFiles(child).enabled,true);assert.equal(engine.canStageFiles(root).enabled,false);const before=engine.getSnapshot();assert.equal(stageLoadingImage(engine,root,'denied-image').result.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.storage.files.size,0);
|
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{assert.equal(engine.canStageFiles(child).enabled,true);assert.equal(engine.canStageFiles(root).enabled,false);const before=engine.getSnapshot();assert.equal(stageLoadingImage(engine,root,'denied-image').result.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.storage.files.size,0);
|
||||||
const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);assert.equal(port.uploads.length,0);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(f.storage.image().formatVersion,12);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceToken='other-source',image=>image.payload.runtime.resources[0][1].loadingAdmission.canvasIds=[root],image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceURL='https://wrong.example/',image=>image.payload.runtime.resources[0][1].loadingAdmission.version=2,image=>{const row=image.payload.runtime.ledger.requests.find(([,row])=>row.kind==='asset.upload')[1];const body=JSON.parse(row.body);body.name='changed';row.body=JSON.stringify(body);},image=>image.formatVersion=11]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}assert.equal(engine.loading.getState().version,6);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('partial-image'),false);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('partial-image').incarnation,2);assert.equal(port.uploads.length,1);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().document.elements.get('partial-image').incarnation,2);assert.equal(f.errors.length,0);
|
const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);assert.equal(port.uploads.length,0);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(f.storage.image().formatVersion,12);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceToken='other-source',image=>image.payload.runtime.resources[0][1].loadingAdmission.canvasIds=[root],image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceURL='https://wrong.example/',image=>image.payload.runtime.resources[0][1].loadingAdmission.version=2,image=>{const row=image.payload.runtime.ledger.requests.find(([,row])=>row.kind==='asset.upload')[1];const body=JSON.parse(row.body);body.name='changed';row.body=JSON.stringify(body);},image=>image.formatVersion=11]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}assert.equal(engine.loading.getState().version,6);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('partial-image'),false);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('partial-image').incarnation,2);assert.equal(port.uploads.length,1);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().document.elements.get('partial-image').incarnation,2);assert.equal(f.errors.length,0);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v6 上传服务成功但 ACK 丢失,复制图片可先本地复用待确认资源;重开查询原上传后释放两份图片文档,不重传字节或改 body。
|
||||||
test('v6 queries a lost upload ACK after reopening and never resubmits bytes or loses the pending image to the source tail',async()=>{
|
test('v6 queries a lost upload ACK after reopening and never resubmits bytes or loses the pending image to the source tail',async()=>{
|
||||||
let now=0;const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),clock={now:()=>now,setTimer:()=>0,clearTimer(){}};port.lose();let engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});try{const {ref}=stageLoadingImage(engine);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.calls.length,0);const request=port.uploads[0];assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');const copyId=await engine.imports.duplicate([{id:'partial-image',incarnation:1}],{viewCanvasId:child});await engine.settle();const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');const copied=copy.stableIds['element:partial-image'];assert.equal(engine.getSnapshot().document.elements.get(copied).data.assetId,ref.id);assert.equal(f.calls.length,0);decodeRecovery(f.storage.image(),registry);await engine.close();now=10_000;engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});await engine.settle();assert.equal(port.uploads.length,1);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.server().elements.get(copied).data.assetId,ref.id);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(port.uploads.length,1);}finally{await engine.close();}
|
let now=0;const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),clock={now:()=>now,setTimer:()=>0,clearTimer(){}};port.lose();let engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});try{const {ref}=stageLoadingImage(engine);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.calls.length,0);const request=port.uploads[0];assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');const copyId=await engine.imports.duplicate([{id:'partial-image',incarnation:1}],{viewCanvasId:child});await engine.settle();const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');const copied=copy.stableIds['element:partial-image'];assert.equal(engine.getSnapshot().document.elements.get(copied).data.assetId,ref.id);assert.equal(f.calls.length,0);decodeRecovery(f.storage.image(),registry);await engine.close();now=10_000;engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});await engine.settle();assert.equal(port.uploads.length,1);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.server().elements.get(copied).data.assetId,ref.id);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(port.uploads.length,1);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v6 可替换完整原始 owner 中的图片引用;undo/redo 保留新旧选择且只上传一次,不因其他范围未加载而丢资源。
|
||||||
test('v6 replaces an image in a complete original owner and preserves both resource choices through undo/redo',async()=>{
|
test('v6 replaces an image in a complete original owner and preserves both resource choices through undo/redo',async()=>{
|
||||||
const image={...c,componentId:'image',data:{title:'Original image',text:'',assetId:'old-asset',src:'https://assets.example/old-asset'}},server=createDocument(root,canvases,[a,b,frame,image,edge]),f=fixture(server),port=assetPort(f),document=createDocument(root,canvases,[a,frame,image]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{const result=engine.stageFiles([{name:'replacement.png',bytes:new Blob(['replacement'],{type:'image/png'})}],([ref],tx)=>tx.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{assetId:ref.id,src:ref.src}}));assert.equal(result.status,'applied');await engine.settle();const replacement=f.server().elements.get('C').data.assetId;assert.notEqual(replacement,'old-asset');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,'old-asset');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,replacement);assert.equal(port.uploads.length,1);decodeRecovery(f.storage.image(),registry);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
const image={...c,componentId:'image',data:{title:'Original image',text:'',assetId:'old-asset',src:'https://assets.example/old-asset'}},server=createDocument(root,canvases,[a,b,frame,image,edge]),f=fixture(server),port=assetPort(f),document=createDocument(root,canvases,[a,frame,image]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{const result=engine.stageFiles([{name:'replacement.png',bytes:new Blob(['replacement'],{type:'image/png'})}],([ref],tx)=>tx.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{assetId:ref.id,src:ref.src}}));assert.equal(result.status,'applied');await engine.settle();const replacement=f.server().elements.get('C').data.assetId;assert.notEqual(replacement,'old-asset');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,'old-asset');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,replacement);assert.equal(port.uploads.length,1);decodeRecovery(f.storage.image(),registry);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// v6 Blob/root 配额失败时本地仍可显示字节,但上传和文档都不发;retry 完成保存后发送一次并保留单一创建历史。
|
||||||
test('v6 failed Blob/root persistence keeps the local picture visible and sends neither upload nor document until retry',async()=>{
|
test('v6 failed Blob/root persistence keeps the local picture visible and sends neither upload nor document until retry',async()=>{
|
||||||
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{await engine.persist();f.storage.broken=true;const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');const display=await engine.displayResource(ref.id);assert.equal(await(await fetch(display.url)).text(),'image bytes');display.release();await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(port.uploads.length,0);assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{f.storage.broken=false;await engine.close();}
|
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{await engine.persist();f.storage.broken=true;const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');const display=await engine.displayResource(ref.id);assert.equal(await(await fetch(display.url)).text(),'image bytes');display.release();await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(port.uploads.length,0);assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{f.storage.broken=false;await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v6 已授权共享图片可接纳到完整 child,不创建上传任务;binding 和准入证据需恢复验证,重开显示/undo/redo 及旧流完成均保持资源身份。
|
||||||
test('v6 adopts an authorized shared image into a complete owner without an upload and restores its binding before the unread tail',async()=>{
|
test('v6 adopts an authorized shared image into a complete owner without an upload and restores its binding before the unread tail',async()=>{
|
||||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),bytes=new Blob(['shared bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),id='shared-adopted',authority='https://assets.example';
|
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),bytes=new Blob(['shared bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),id='shared-adopted',authority='https://assets.example';
|
||||||
const cache=new Map(),commit=f.storage.commit;f.storage.assetDepot={async read(_leader,_authority,id){return cache.get(id);},async collect(){throw Error('unexpected GC');}};f.storage.commit=async(leader,batch)=>{const result=await commit(leader,batch);for(const write of batch.depot?.writes??[])cache.set(write.ref.asset.id,{ref:write.ref,bytes:write.bytes??cache.get(write.ref.asset.id)?.bytes});return result;};
|
const cache=new Map(),commit=f.storage.commit;f.storage.assetDepot={async read(_leader,_authority,id){return cache.get(id);},async collect(){throw Error('unexpected GC');}};f.storage.commit=async(leader,batch)=>{const result=await commit(leader,batch);for(const write of batch.depot?.writes??[])cache.set(write.ref.asset.id,{ref:write.ref,bytes:write.bytes??cache.get(write.ref.asset.id)?.bytes});return result;};
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 旧快照到 canonical 身份迁移的持久请求状态机测试。先 drain 旧写、读取最新源版本、保存冻结 body,再派发迁移。
|
||||||
|
* 未知结果保留原 ID/字节,恢复先查询后按原请求重试;只有验证过的终态才可清理恢复材料或放开旧编辑。
|
||||||
|
* 内存存储与假服务端注入响应丢失、版本冲突及配额问题;真实 UI/Web Locks 场景由 renderer 集成探针另测。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {createCanvasMigration,readCanvasMigration,canvasMigrationStorageKey,migrationBlocksCanvas} from '../../src/lib/canvas-migration.ts';
|
import {createCanvasMigration,readCanvasMigration,canvasMigrationStorageKey,migrationBlocksCanvas} from '../../src/lib/canvas-migration.ts';
|
||||||
|
|
||||||
|
// 固定 UUID 作用域及新元素身份;源旧 ID 为 a,成功迁移须返回声明映射到新元素的证明。
|
||||||
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),scope={accountId:id(3),workspaceId:id(4)},registry=createRegistry(builtinComponents());
|
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),scope={accountId:id(3),workspaceId:id(4)},registry=createRegistry(builtinComponents());
|
||||||
|
// 保存迁移字符串、回执、冻结 body 和请求记录,开关区分提交前失败、提交后丢 ACK、明确拒绝和篡改响应。
|
||||||
function fixture(){
|
function fixture(){
|
||||||
const records=new Map(),receipts=new Map(),bodies=new Map(),calls=[];
|
const records=new Map(),receipts=new Map(),bodies=new Map(),calls=[];
|
||||||
const f={records,receipts,calls,version:1,canonical:false,current:true,quota:false,loseResponse:false,failBeforeCommit:false,reject:false,alter:undefined};let sequence=100;
|
const f={records,receipts,calls,version:1,canonical:false,current:true,quota:false,loseResponse:false,failBeforeCommit:false,reject:false,alter:undefined};let sequence=100;
|
||||||
|
// 模拟 localStorage;quota 在 setItem 前抛错,removeItem 真正删除恢复记录。
|
||||||
const storage={getItem:key=>records.get(key)??null,setItem(key,value){if(f.quota)throw Error('quota');records.set(key,value);},removeItem:key=>records.delete(key)};
|
const storage={getItem:key=>records.get(key)??null,setItem(key,value){if(f.quota)throw Error('quota');records.set(key,value);},removeItem:key=>records.delete(key)};
|
||||||
|
// 完整 canonical 模型与新身份 id(20),供成功回执的版本、元素类型和 identity_map 交叉校验。
|
||||||
const model=version=>({documentFormatVersion:1,projectId:root,complete:true,canvases:[{id:root,parent:null}],elements:[{id:id(20),incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'body'},geometry:{x:0,y:0,width:240,height:72,rotation:0}}],versions:{[root]:version},tombstones:{},removedCanvasIds:[]});
|
const model=version=>({documentFormatVersion:1,projectId:root,complete:true,canvases:[{id:root,parent:null}],elements:[{id:id(20),incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'body'},geometry:{x:0,y:0,width:240,height:72,rotation:0}}],versions:{[root]:version},tombstones:{},removedCanvasIds:[]});
|
||||||
|
// GET 分别查迁移需求/完整模型和原回执;POST 先断言已持久化同一 body,再按 requestId 防止重复应用。
|
||||||
const transport=async input=>{
|
const transport=async input=>{
|
||||||
calls.push(input);
|
calls.push(input);
|
||||||
if(input.method==='GET'&&input.path.endsWith('/model'))return f.canonical?{status:200,body:{model:model(f.version)}}:{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:f.version}}};
|
if(input.method==='GET'&&input.path.endsWith('/model'))return f.canonical?{status:200,body:{model:model(f.version)}}:{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:f.version}}};
|
||||||
@@ -17,10 +27,13 @@ function fixture(){
|
|||||||
const response=known?{status:200,body:known.body}:{status:404,body:{status:'unknown',request_id:key,error:'operation_receipt_unknown'}};
|
const response=known?{status:200,body:known.body}:{status:404,body:{status:'unknown',request_id:key,error:'operation_receipt_unknown'}};
|
||||||
return f.alter?f.alter(response,input):response;
|
return f.alter?f.alter(response,input):response;
|
||||||
}
|
}
|
||||||
|
// 网络派发前直接读取保存字符串,对照请求体,证明持久化屏障而非只检查内存中存在请求。
|
||||||
assert.equal(JSON.parse(storage.getItem(canvasMigrationStorageKey(scope))).body,input.body,'request must be durable before POST');
|
assert.equal(JSON.parse(storage.getItem(canvasMigrationStorageKey(scope))).body,input.body,'request must be durable before POST');
|
||||||
assert.equal(input.responseFormat,'canvas-v2');
|
assert.equal(input.responseFormat,'canvas-v2');
|
||||||
|
// 提交前失败没有服务端回执;恢复只能查询 unknown 后精确重试,不能据传输异常推定 applied。
|
||||||
if(f.failBeforeCommit)throw Error('connection lost before commit');
|
if(f.failBeforeCommit)throw Error('connection lost before commit');
|
||||||
const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId);
|
const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId);
|
||||||
|
// 已知 key 必须使用相同冻结字节;新 key 根据 reject 开关生成可信零写入拒绝或带映射/完整模型的成功。
|
||||||
let response=receipts.get(input.requestId);
|
let response=receipts.get(input.requestId);
|
||||||
if(response){assert.equal(bodies.get(input.requestId),input.body,'retry changed frozen bytes');}
|
if(response){assert.equal(bodies.get(input.requestId),input.body,'retry changed frozen bytes');}
|
||||||
else{
|
else{
|
||||||
@@ -28,13 +41,17 @@ function fixture(){
|
|||||||
receipts.set(input.requestId,response);bodies.set(input.requestId,input.body);
|
receipts.set(input.requestId,response);bodies.set(input.requestId,input.body);
|
||||||
if(!f.reject){f.version++;f.canonical=true;}
|
if(!f.reject){f.version++;f.canonical=true;}
|
||||||
}
|
}
|
||||||
|
// 此时服务端事实与回执已保存,再模拟 ACK 丢失;客户端看到错误也不能生成另一次迁移。
|
||||||
if(f.loseResponse)throw Error('connection lost after commit');
|
if(f.loseResponse)throw Error('connection lost after commit');
|
||||||
return f.alter?f.alter(structuredClone(response),input):response;
|
return f.alter?f.alter(structuredClone(response),input):response;
|
||||||
};
|
};
|
||||||
|
// 组装迁移控制器,默认 lock 只执行 work(不证明真实互斥);current、createId 和 signal 可被用例注入。
|
||||||
f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra});
|
f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra});
|
||||||
|
// 从全部调用中过滤 POST,分别检查旧请求精确重试与明确重新准备产生新请求。
|
||||||
f.posts=()=>calls.filter(call=>call.method==='POST');return f;
|
f.posts=()=>calls.filter(call=>call.method==='POST');return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drain 必须在任何读请求前完成,之后重新读源版本;POST 使用 drain 后的版本和预先持久化 requestId,成功后清理记录。
|
||||||
test('migration drains first, refreshes source versions and persists exact request identity before dispatch',async()=>{
|
test('migration drains first, refreshes source versions and persists exact request identity before dispatch',async()=>{
|
||||||
const f=fixture(),migration=f.make();let drained=false;
|
const f=fixture(),migration=f.make();let drained=false;
|
||||||
const result=await migration.start(root,async()=>{assert.equal(f.calls.length,0);drained=true;f.version=4;});
|
const result=await migration.start(root,async()=>{assert.equal(f.calls.length,0);drained=true;f.version=4;});
|
||||||
@@ -43,6 +60,7 @@ test('migration drains first, refreshes source versions and persists exact reque
|
|||||||
assert.equal(f.posts()[0].requestId,JSON.parse(f.posts()[0].body).request_id);
|
assert.equal(f.posts()[0].requestId,JSON.parse(f.posts()[0].body).request_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 服务提交成功但响应丢失,重建控制器先查原 key 即恢复成功;不再次 drain、不分配新 ID,也不发第二个 POST。
|
||||||
test('lost committed migration ACK is recovered by original query after reload without flushing or invoking twice',async()=>{
|
test('lost committed migration ACK is recovered by original query after reload without flushing or invoking twice',async()=>{
|
||||||
const f=fixture();f.loseResponse=true;const first=f.make();
|
const f=fixture();f.loseResponse=true;const first=f.make();
|
||||||
assert.equal((await first.start(root,async()=>{})).status,'pending');const job=first.read();
|
assert.equal((await first.start(root,async()=>{})).status,'pending');const job=first.read();
|
||||||
@@ -52,6 +70,7 @@ test('lost committed migration ACK is recovered by original query after reload w
|
|||||||
assert.equal(f.posts().length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/operations/'+job.id)));assert.equal(recovered.read(),undefined);
|
assert.equal(f.posts().length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/operations/'+job.id)));assert.equal(recovered.read(),undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 提交前断线的 durable 请求先 query unknown,再保留原 ID 和包含额外空白的 body 精确重试,不能重序列化。
|
||||||
test('a durable uncommitted request queries unknown then retries only its exact original body and key',async()=>{
|
test('a durable uncommitted request queries unknown then retries only its exact original body and key',async()=>{
|
||||||
const f=fixture();f.failBeforeCommit=true;const first=f.make();await first.start(root,async()=>{});
|
const f=fixture();f.failBeforeCommit=true;const first=f.make();await first.start(root,async()=>{});
|
||||||
const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false;
|
const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false;
|
||||||
@@ -61,6 +80,7 @@ test('a durable uncommitted request queries unknown then retries only its exact
|
|||||||
assert.equal(f.calls.at(-2).method,'GET');assert.equal(f.calls.at(-2).body,undefined);
|
assert.equal(f.calls.at(-2).method,'GET');assert.equal(f.calls.at(-2).body,undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 存储配额或旧 writer drain 失败均不得发迁移;drain 失败也不能留下新恢复任务或触发网络读取。
|
||||||
test('storage and legacy-drain failure prevent dispatch and do not silently discard recovery state',async()=>{
|
test('storage and legacy-drain failure prevent dispatch and do not silently discard recovery state',async()=>{
|
||||||
const f=fixture();f.quota=true;
|
const f=fixture();f.quota=true;
|
||||||
await assert.rejects(()=>f.make().start(root,async()=>{}),/quota/);assert.equal(f.posts().length,0);
|
await assert.rejects(()=>f.make().start(root,async()=>{}),/quota/);assert.equal(f.posts().length,0);
|
||||||
@@ -69,6 +89,7 @@ test('storage and legacy-drain failure prevent dispatch and do not silently disc
|
|||||||
assert.equal(f.calls.length,0);assert.equal(f.records.size,0);
|
assert.equal(f.calls.length,0);assert.equal(f.records.size,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 可信 terminal not-applied 才将 phase 置 rejected 并释放编辑 gate;resume 不重发,显式 start 才刷新版本并生成新 key。
|
||||||
test('only verified terminal rejection releases the editing gate; a new explicit preparation refreshes and allocates',async()=>{
|
test('only verified terminal rejection releases the editing gate; a new explicit preparation refreshes and allocates',async()=>{
|
||||||
const f=fixture();f.reject=true;const migration=f.make();const result=await migration.start(root,async()=>{});
|
const f=fixture();f.reject=true;const migration=f.make();const result=await migration.start(root,async()=>{});
|
||||||
assert.equal(result.status,'rejected');const rejected=migration.read();assert.equal(rejected.phase,'rejected');assert.equal(migrationBlocksCanvas(rejected,root),false);
|
assert.equal(result.status,'rejected');const rejected=migration.read();assert.equal(rejected.phase,'rejected');assert.equal(migrationBlocksCanvas(rejected,root),false);
|
||||||
@@ -77,6 +98,7 @@ test('only verified terminal rejection releases the editing gate; a new explicit
|
|||||||
assert.equal((await migration.start(root,async()=>{})).status,'applied');assert.notEqual(f.posts()[1].requestId,rejected.id);assert.deepEqual(JSON.parse(f.posts()[1].body).expected_versions,{[root]:7});
|
assert.equal((await migration.start(root,async()=>{})).status,'applied');assert.notEqual(f.posts()[1].requestId,rejected.id);assert.deepEqual(JSON.parse(f.posts()[1].body).expected_versions,{[root]:7});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 裸 403、错 key/源版本/空或错类型/跨 canvas 映射不构成成功证明,保持 pending gate;原回执查询修复后仍只有一次 POST。
|
||||||
test('bare rejection and malformed or foreign success retain the frozen request until the original query repairs it',async()=>{
|
test('bare rejection and malformed or foreign success retain the frozen request until the original query repairs it',async()=>{
|
||||||
for(const alter of [
|
for(const alter of [
|
||||||
response=>({status:403,body:{error:'forbidden'}}),
|
response=>({status:403,body:{error:'forbidden'}}),
|
||||||
@@ -92,6 +114,7 @@ test('bare rejection and malformed or foreign success retain the frozen request
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未来迁移格式及同 workspace 另一 pending 根不能被覆盖;换账户读取不同存储键,不泄漏旧请求。
|
||||||
test('future/corrupt migration material and another pending project cannot be overwritten',async()=>{
|
test('future/corrupt migration material and another pending project cannot be overwritten',async()=>{
|
||||||
const f=fixture(),key=canvasMigrationStorageKey(scope);f.storage.setItem(key,JSON.stringify({version:2,payload:'retain'}));
|
const f=fixture(),key=canvasMigrationStorageKey(scope);f.storage.setItem(key,JSON.stringify({version:2,payload:'retain'}));
|
||||||
const saved=f.storage.getItem(key);await assert.rejects(()=>f.make().start(root,async()=>assert.fail()),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.calls.length,0);
|
const saved=f.storage.getItem(key);await assert.rejects(()=>f.make().start(root,async()=>assert.fail()),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.calls.length,0);
|
||||||
@@ -100,6 +123,7 @@ test('future/corrupt migration material and another pending project cannot be ov
|
|||||||
assert.equal(readCanvasMigration(f.storage,{...scope,accountId:id(99)}),undefined);
|
assert.equal(readCanvasMigration(f.storage,{...scope,accountId:id(99)}),undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 响应回来时身份已变或 signal abort,不能清理旧账户恢复材料;回到有效身份后查询原回执,不重复迁移。
|
||||||
test('identity changes and aborted lifetimes cannot clear an old account request on a late success',async()=>{
|
test('identity changes and aborted lifetimes cannot clear an old account request on a late success',async()=>{
|
||||||
const f=fixture();f.alter=response=>{f.current=false;return response;};
|
const f=fixture();f.alter=response=>{f.current=false;return response;};
|
||||||
await assert.rejects(()=>f.make().start(root,async()=>{}),/identity_changed/);assert.equal(f.records.size,1);
|
await assert.rejects(()=>f.make().start(root,async()=>{}),/identity_changed/);assert.equal(f.records.size,1);
|
||||||
@@ -108,6 +132,7 @@ test('identity changes and aborted lifetimes cannot clear an old account request
|
|||||||
await assert.rejects(()=>g.make({signal:controller.signal}).start(root,async()=>{}),{name:'AbortError'});assert.equal(g.records.size,1);assert.equal(g.posts()[0].signal,controller.signal);
|
await assert.rejects(()=>g.make({signal:controller.signal}).start(root,async()=>{}),{name:'AbortError'});assert.equal(g.records.size,1);assert.equal(g.posts()[0].signal,controller.signal);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同控制器重入 resume 被 migration_busy 拒绝;完成后新控制器识别已 canonical,只返回 already-migrated 不再 POST。
|
||||||
test('reentrant actions cannot create a second in-flight migration and already migrated projects do not POST',async()=>{
|
test('reentrant actions cannot create a second in-flight migration and already migrated projects do not POST',async()=>{
|
||||||
const f=fixture();let entered,release;const started=new Promise(resolve=>entered=resolve),gate=new Promise(resolve=>release=resolve);
|
const f=fixture();let entered,release;const started=new Promise(resolve=>entered=resolve),gate=new Promise(resolve=>release=resolve);
|
||||||
const original=f.make();
|
const original=f.make();
|
||||||
@@ -117,6 +142,7 @@ test('reentrant actions cannot create a second in-flight migration and already m
|
|||||||
assert.equal((await original.start(root,async()=>{})).status,'already-migrated');assert.equal(f.posts().length,1);
|
assert.equal((await original.start(root,async()=>{})).status,'already-migrated');assert.equal(f.posts().length,1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 成功后清理记录失败仍保留可恢复请求,重试查询即可;假存储吞掉 setItem 时以 migration_not_durable 拒绝派发。
|
||||||
test('failed result retirement remains recoverable and a storage port that drops writes cannot dispatch',async()=>{
|
test('failed result retirement remains recoverable and a storage port that drops writes cannot dispatch',async()=>{
|
||||||
const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}};
|
const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}};
|
||||||
await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/);
|
await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/);
|
||||||
@@ -124,6 +150,7 @@ test('failed result retirement remains recoverable and a storage port that drops
|
|||||||
const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0);
|
const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 合法空模型允许 identity_map 为空,不应为了满足校验凭空生成不存在元素的映射。
|
||||||
test('a valid empty migration has no fabricated identity entries',async()=>{
|
test('a valid empty migration has no fabricated identity entries',async()=>{
|
||||||
const f=fixture();f.alter=response=>({...response,body:{...response.body,identity_map:[],model:{...response.body.model,elements:[]}}});
|
const f=fixture();f.alter=response=>({...response,body:{...response.body,identity_map:[],model:{...response.body.model,elements:[]}}});
|
||||||
assert.equal((await f.make().start(root,async()=>{})).status,'applied');assert.equal(f.records.size,0);
|
assert.equal((await f.make().start(root,async()=>{})).status,'applied');assert.equal(f.records.size,0);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Foundry 模型 loader、RuntimeHost 共享读取及画布 entry 注入测试。只读加载不应提前打开 workspace/project 存储。
|
||||||
|
* 并发请求可以共享进行中的读取,但空闲 canonical 快照不自动成为下次打开的权威基线;身份退役隔离迟到预览。
|
||||||
|
*/
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -5,13 +9,20 @@ import { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
|
|||||||
import { loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
|
import { loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
|
||||||
import { createFoundryModelLoader } from '../../src/lib/canvas-service/model-loader.ts';
|
import { createFoundryModelLoader } from '../../src/lib/canvas-service/model-loader.ts';
|
||||||
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
|
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
|
||||||
|
// 确定性 UUID 工厂,满足 service 的输入校验并区分账户、workspace 与根 canvas。
|
||||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||||
|
// 当前身份和 registry;scope 中 projectId 尚未知,由 canonical 模型解析得到真实项目根。
|
||||||
const account = id(1), workspace = id(2), root = id(3), registry = createRegistry(builtinComponents());
|
const account = id(1), workspace = id(2), root = id(3), registry = createRegistry(builtinComponents());
|
||||||
|
// 完整模型查询绑定权限 epoch、导航代次、格式和来源,防止不同读取用途误共享。
|
||||||
const scope = { accountId: account, workspaceId: workspace, projectId: null, canvasId: root, authEpoch: 1, navigationGeneration: 7, format: 'canonical-model-v1', source: 'foundry', viewKey: 'canonical-project', queryFingerprint: 'full-project' };
|
const scope = { accountId: account, workspaceId: workspace, projectId: null, canvasId: root, authEpoch: 1, navigationGeneration: 7, format: 'canonical-model-v1', source: 'foundry', viewKey: 'canonical-project', queryFingerprint: 'full-project' };
|
||||||
|
// 手动 gate 控制首次响应及尾部预览返回,精确安排取消/身份退役。
|
||||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
||||||
|
// 推进一次事件循环,让共享 loader 和观察者执行至可检查的状态。
|
||||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
// 生成版本可变的完整 canonical 响应,标题可用于检测是否误用了旧账户结果。
|
||||||
const reply = (version = 1, title = account) => ({ status: 200, body: { model: { documentFormatVersion: 1, projectId: root, complete: true, canvases: [{ id: root, parent: null }], elements: [{ id: 'node', incarnation: 1, ownerCanvasId: root, componentId: 'text', dataVersion: 1, structure: 'node', data: { title, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0 } }], tombstones: {}, versions: { [root]: version }, removedCanvasIds: [] } } });
|
const reply = (version = 1, title = account) => ({ status: 200, body: { model: { documentFormatVersion: 1, projectId: root, complete: true, canvases: [{ id: root, parent: null }], elements: [{ id: 'node', incarnation: 1, ownerCanvasId: root, componentId: 'text', dataVersion: 1, structure: 'node', data: { title, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0 } }], tombstones: {}, versions: { [root]: version }, removedCanvasIds: [] } } });
|
||||||
|
|
||||||
|
// JSON adapter 只发一个已验证完整块,绑定 query/project、canvas 覆盖和版本,GET 沿用原 AbortSignal。
|
||||||
test('Foundry adapter emits one validated complete chunk with bound query, project, coverage and version metadata', async () => {
|
test('Foundry adapter emits one validated complete chunk with bound query, project, coverage and version metadata', async () => {
|
||||||
const abort = new AbortController(), calls = [];
|
const abort = new AbortController(), calls = [];
|
||||||
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async input => { calls.push(input); return reply(3); } });
|
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async input => { calls.push(input); return reply(3); } });
|
||||||
@@ -20,12 +31,14 @@ test('Foundry adapter emits one validated complete chunk with bound query, proje
|
|||||||
assert.deepEqual(chunks[0].coverage, [`canvas:${root}`]); assert.equal(chunks[0].serverVersions[root], 3); assert.ok(chunks[0].byteLength > 0); assert.equal(chunks[0].value.document.elements.get('node').data.title, account);
|
assert.deepEqual(chunks[0].coverage, [`canvas:${root}`]); assert.equal(chunks[0].serverVersions[root], 3); assert.ok(chunks[0].byteLength > 0); assert.equal(chunks[0].value.document.elements.get('node').data.title, account);
|
||||||
assert.strictEqual(calls[0].signal, abort.signal); assert.equal(calls[0].method, 'GET');
|
assert.strictEqual(calls[0].signal, abort.signal); assert.equal(calls[0].method, 'GET');
|
||||||
});
|
});
|
||||||
|
// 局部模型、查询 canvas 不在模型及不匹配迁移头均不能冒充初始就绪,必须在 yield 任何块前拒绝。
|
||||||
test('partial canonical replies, foreign requested canvases and unbound migration headers cannot masquerade as initial readiness', async () => {
|
test('partial canonical replies, foreign requested canvases and unbound migration headers cannot masquerade as initial readiness', async () => {
|
||||||
for (const [response, expected] of [[{ ...reply(), body: { model: { ...reply().body.model, complete: false } } }, 'incomplete_initial_model'], [reply(), 'project_mismatch'], [{ status: 409, body: { error: 'canonical_migration_required', document_format_version: 1, root_id: root, source_versions: { [root]: 1 } } }, 'project_mismatch']]) {
|
for (const [response, expected] of [[{ ...reply(), body: { model: { ...reply().body.model, complete: false } } }, 'incomplete_initial_model'], [reply(), 'project_mismatch'], [{ status: 409, body: { error: 'canonical_migration_required', document_format_version: 1, root_id: root, source_versions: { [root]: 1 } } }, 'project_mismatch']]) {
|
||||||
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async () => response }), query = expected === 'incomplete_initial_model' ? scope : { ...scope, canvasId: id(99) };
|
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async () => response }), query = expected === 'incomplete_initial_model' ? scope : { ...scope, canvasId: id(99) };
|
||||||
await assert.rejects(async () => { for await (const _ of port(query, new AbortController().signal)) assert.fail('invalid response yielded a chunk'); }, { code: expected });
|
await assert.rejects(async () => { for await (const _ of port(query, new AbortController().signal)) assert.fail('invalid response yielded a chunk'); }, { code: expected });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 同身份/workspace 并发读取共享端口,取消一方不伤另一方;完成释放后下次重新读取新版本,始终不打开 workspace runtime。
|
||||||
test('Host shares concurrent model reads per identity/workspace but never reuses an idle authoritative snapshot', async () => {
|
test('Host shares concurrent model reads per identity/workspace but never reuses an idle authoritative snapshot', async () => {
|
||||||
const gate = deferred(), calls = []; let factories = 0, version = 1;
|
const gate = deferred(), calls = []; let factories = 0, version = 1;
|
||||||
const host = createRuntimeHost({ registry, workspace() { assert.fail('readonly load opened a project/storage runtime'); }, modelLoader(accountId, workspaceId, _epoch, current) {
|
const host = createRuntimeHost({ registry, workspace() { assert.fail('readonly load opened a project/storage runtime'); }, modelLoader(accountId, workspaceId, _epoch, current) {
|
||||||
@@ -38,6 +51,7 @@ test('Host shares concurrent model reads per identity/workspace but never reuses
|
|||||||
version = 2; assert.equal((await host.loadModel(account, workspace, root)).versions.get(root), 2); assert.equal(calls.length, 2); assert.equal(host.getWorkspaces().size, 0);
|
version = 2; assert.equal((await host.loadModel(account, workspace, root)).versions.get(root), 2); assert.equal(calls.length, 2); assert.equal(host.getWorkspaces().size, 0);
|
||||||
} finally { gate.resolve(); await host.close(); }
|
} finally { gate.resolve(); await host.close(); }
|
||||||
});
|
});
|
||||||
|
// 身份退役关闭旧 loader 并 abort 原信号,新账户用新读取;旧响应迟到不能覆盖当前 Host 身份或模型。
|
||||||
test('Host retirement aborts the old read and a late response cannot pollute the new account reader', async () => {
|
test('Host retirement aborts the old read and a late response cannot pollute the new account reader', async () => {
|
||||||
const gate = deferred(), calls = [];
|
const gate = deferred(), calls = [];
|
||||||
const host = createRuntimeHost({ registry, workspace() { assert.fail('unexpected project open'); }, modelLoader(accountId, workspaceId) {
|
const host = createRuntimeHost({ registry, workspace() { assert.fail('unexpected project open'); }, modelLoader(accountId, workspaceId) {
|
||||||
@@ -49,6 +63,7 @@ test('Host retirement aborts the old read and a late response cannot pollute the
|
|||||||
gate.resolve(); await tick(); assert.equal(host.identity().accountId, id(8)); assert.equal(calls[0].input.signal.aborted, true); assert.equal(calls.length, 2);
|
gate.resolve(); await tick(); assert.equal(host.identity().accountId, id(8)); assert.equal(calls[0].input.signal.aborted, true); assert.equal(calls.length, 2);
|
||||||
} finally { gate.resolve(); await host.close(); }
|
} finally { gate.resolve(); await host.close(); }
|
||||||
});
|
});
|
||||||
|
// entry 解析 slug 后传入真实 account/workspace/canvas 与导航上下文,走注入 readModel;canonical 失败不能偷退回旧 canvas 接口。
|
||||||
test('entry injection uses resolved IDs and navigation context while preserving canonical-first legacy policy', async () => {
|
test('entry injection uses resolved IDs and navigation context while preserving canonical-first legacy policy', async () => {
|
||||||
const board = { id: root, workspace_id: workspace, slug: 'board', kind: 'board', title: 'Board', version: 1 }, inputs = [], reads = [];
|
const board = { id: root, workspace_id: workspace, slug: 'board', kind: 'board', title: 'Board', version: 1 }, inputs = [], reads = [];
|
||||||
const options = { workspaceRef: 'workspace-slug', boardRef: 'board', registry, navigationGeneration: 9, request: async path => { reads.push(path); return path === 'profile' ? { id: account } : board; },
|
const options = { workspaceRef: 'workspace-slug', boardRef: 'board', registry, navigationGeneration: 9, request: async path => { reads.push(path); return path === 'profile' ? { id: account } : board; },
|
||||||
@@ -57,10 +72,12 @@ test('entry injection uses resolved IDs and navigation context while preserving
|
|||||||
await assert.rejects(() => loadCanvasEntry({ ...options, readModel: async () => { throw Object.assign(Error('forbidden'), { code: 'forbidden' }); } }), { code: 'forbidden' });
|
await assert.rejects(() => loadCanvasEntry({ ...options, readModel: async () => { throw Object.assign(Error('forbidden'), { code: 'forbidden' }); } }), { code: 'forbidden' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 两个预览观察者共享一个渐进源,取消一个后它不再收到后缀;另一方完成,Host 未创建任何存储 workspace。
|
||||||
test('Host previews share one source but cancelled observers never receive a late prefix or open storage',async()=>{
|
test('Host previews share one source but cancelled observers never receive a late prefix or open storage',async()=>{
|
||||||
const gate=deferred(),aStates=[],bStates=[];let reads=0;
|
const gate=deferred(),aStates=[],bStates=[];let reads=0;
|
||||||
const first=reply().body.model.elements[0],second={...first,id:'second'},canvases=[{id:root,parent:null}],serverVersions={[root]:1};
|
const first=reply().body.model.elements[0],second={...first,id:'second'},canvases=[{id:root,parent:null}],serverVersions={[root]:1};
|
||||||
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened storage');},modelLoader(){return async function*(scope){
|
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened storage');},modelLoader(){return async function*(scope){
|
||||||
|
// 每个原始读取共用 token/版本;part 将状态包装成连续序号块,gate 只阻塞后缀。
|
||||||
reads++;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'one-read',serverVersions,permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
|
reads++;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'one-read',serverVersions,permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
|
||||||
yield part(0,{status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:2});
|
yield part(0,{status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:2});
|
||||||
yield part(1,{status:'receiving',projectId:root,canvases:[],elements:[first],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:1,totalElements:2});
|
yield part(1,{status:'receiving',projectId:root,canvases:[],elements:[first],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:1,totalElements:2});
|
||||||
@@ -71,9 +88,11 @@ test('Host previews share one source but cancelled observers never receive a lat
|
|||||||
const abort=new AbortController();const a=host.loadModel(account,workspace,root,{signal:abort.signal,onPreview:value=>aStates.push(value)}),b=host.loadModel(account,workspace,root,{onPreview:value=>bStates.push(value)});
|
const abort=new AbortController();const a=host.loadModel(account,workspace,root,{signal:abort.signal,onPreview:value=>aStates.push(value)}),b=host.loadModel(account,workspace,root,{onPreview:value=>bStates.push(value)});
|
||||||
try{while(!aStates.some(value=>value.receivedElements===1))await tick();assert.equal(reads,1);assert.equal(aStates.at(-1).document.elements.size,1);const rejected=assert.rejects(a,{code:'load_aborted'});abort.abort();await rejected;const count=aStates.length;gate.resolve();const complete=await b;assert.equal(complete.document.elements.size,2);assert.equal(aStates.length,count);assert.equal(bStates.at(-1).document.elements.size,2);assert.equal(host.getWorkspaces().size,0);}finally{gate.resolve();await host.close();}
|
try{while(!aStates.some(value=>value.receivedElements===1))await tick();assert.equal(reads,1);assert.equal(aStates.at(-1).document.elements.size,1);const rejected=assert.rejects(a,{code:'load_aborted'});abort.abort();await rejected;const count=aStates.length;gate.resolve();const complete=await b;assert.equal(complete.document.elements.size,2);assert.equal(aStates.length,count);assert.equal(bStates.at(-1).document.elements.size,2);assert.equal(host.getWorkspaces().size,0);}finally{gate.resolve();await host.close();}
|
||||||
});
|
});
|
||||||
|
// onPreview 抛错经 onObserverError 隔离,不阻止 canonical ready;身份退役后不能继续通知第二次读取的预览。
|
||||||
test('preview callback failures do not stop canonical loading and identity retirement fences future previews',async()=>{
|
test('preview callback failures do not stop canonical loading and identity retirement fences future previews',async()=>{
|
||||||
const gate=deferred(),secondGate=deferred(),errors=[],states=[];let reading=0;
|
const gate=deferred(),secondGate=deferred(),errors=[],states=[];let reading=0;
|
||||||
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened a runtime');},onObserverError:error=>errors.push(error),modelLoader(){return async function*(scope){
|
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened a runtime');},onObserverError:error=>errors.push(error),modelLoader(){return async function*(scope){
|
||||||
|
// 两次查询使用不同 gate,分别检查回调异常隔离和身份退役;每次源仍有独立接收/ready 阶段。
|
||||||
const pass=++reading;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'read',serverVersions:{[root]:1},permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
|
const pass=++reading;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'read',serverVersions:{[root]:1},permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
|
||||||
yield part(0,{status:'receiving',projectId:root,canvases:[{id:root,parent:null}],elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:0});await(pass===1?gate.promise:secondGate.promise);
|
yield part(0,{status:'receiving',projectId:root,canvases:[{id:root,parent:null}],elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:0});await(pass===1?gate.promise:secondGate.promise);
|
||||||
yield part(1,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,[{id:root,parent:null}]),versions:new Map([[root,1]])},true);
|
yield part(1,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,[{id:root,parent:null}]),versions:new Map([[root,1]])},true);
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 渐进 canonical 模型的只读预览投影测试。接收进度与合法可发布前缀不同:入口/归属/端口/端点依赖未闭合时先等待。
|
||||||
|
* 每次发布需是合法文档,失败保持上次快照并关闭预览;最终完整模型还需与已显示内容一致,不能直接变成可编辑半模型。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createRegistry,builtinComponents,createDocument,validateDocument} from '../../src/lib/canvas-core/index.ts';
|
import {createRegistry,builtinComponents,createDocument,validateDocument} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts';
|
import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts';
|
||||||
|
// 内置类型定义及 root/child 身份;frame 端口与 child 成员形成依赖闭环。
|
||||||
const registry=createRegistry(builtinComponents()),root='root',child='child';
|
const registry=createRegistry(builtinComponents()),root='root',child='child';
|
||||||
|
// 文本节点工厂,可替换 owner,用于构造已就绪节点、等待成员及非法记录。
|
||||||
const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
|
const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
|
||||||
|
// F 在 root,端口引用 child 中 A;child 又以 F 为入口,必须闭合后一起显示。
|
||||||
const frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[{id:'p',nodeId:'A',direction:'output',handle:'right'}]}};
|
const frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[{id:'p',nodeId:'A',direction:'output',handle:'right'}]}};
|
||||||
|
// A 与 B 作为跨分区连线端点;E 归共同 root,不能在任一端点缺失时显示成完整边。
|
||||||
const a=node('A',child),b=node('B'),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'B'},path:null};
|
const a=node('A',child),b=node('B'),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'B'},path:null};
|
||||||
|
// 源 canvas 树和版本表;预览开始时虽已知 child 关系,入口尚未来到仍不能发布 child。
|
||||||
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],versions=new Map([[root,1],[child,1]]);
|
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],versions=new Map([[root,1],[child,1]]);
|
||||||
|
// 空接收头声明总元素数,complete 仍由最终 ready 状态决定,不由 received==total 推定。
|
||||||
const head=(total=4)=>({status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions,receivedElements:0,totalElements:total});
|
const head=(total=4)=>({status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions,receivedElements:0,totalElements:total});
|
||||||
|
// 后续 delta 不重复 canvas 头,只带新元素和接收计数,测试增量累积而非整文档替换。
|
||||||
const delta=(elements,received,total=4)=>({...head(total),canvases:[],elements,receivedElements:received});
|
const delta=(elements,received,total=4)=>({...head(total),canvases:[],elements,receivedElements:received});
|
||||||
|
|
||||||
|
// 先收 A/E 仍等待,独立 B 可先显示;F 到来才闭合 child/端口/连线并发布合法前缀,旧文档不变,收到全部元素仍不等于 complete。
|
||||||
test('partial model waits for entry/owner/port cycles and edge endpoints; publishes one closed legal prefix',()=>{
|
test('partial model waits for entry/owner/port cycles and edge endpoints; publishes one closed legal prefix',()=>{
|
||||||
const preview=createModelPreview(registry);const first=preview.update(head());validateDocument(first.document,registry);assert.equal(first.document.canvases.size,1);
|
const preview=createModelPreview(registry);const first=preview.update(head());validateDocument(first.document,registry);assert.equal(first.document.canvases.size,1);
|
||||||
const second=preview.update(delta([a,edge],2));assert.equal(second.document.elements.size,0);assert.equal(second.waitingElements,2);assert.strictEqual(second.document,first.document);
|
const second=preview.update(delta([a,edge],2));assert.equal(second.document.elements.size,0);assert.equal(second.waitingElements,2);assert.strictEqual(second.document,first.document);
|
||||||
@@ -17,32 +29,38 @@ test('partial model waits for entry/owner/port cycles and edge endpoints; publis
|
|||||||
const fourth=preview.update(delta([frame],4));validateDocument(fourth.document,registry);assert.equal(fourth.document.elements.size,4);assert.equal(fourth.document.canvases.size,2);assert.equal(fourth.waitingElements,0);assert.equal(fourth.document.elements.get('E').ownerCanvasId,root);
|
const fourth=preview.update(delta([frame],4));validateDocument(fourth.document,registry);assert.equal(fourth.document.elements.size,4);assert.equal(fourth.document.canvases.size,2);assert.equal(fourth.waitingElements,0);assert.equal(fourth.document.elements.get('E').ownerCanvasId,root);
|
||||||
assert.equal(first.document.elements.size,0);assert.equal(third.document.elements.size,1);assert.equal(fourth.complete,false);preview.close();assert.throws(()=>preview.update(delta([],4)),{code:'model_preview_closed'});
|
assert.equal(first.document.elements.size,0);assert.equal(third.document.elements.size,1);assert.equal(fourth.complete,false);preview.close();assert.throws(()=>preview.update(delta([],4)),{code:'model_preview_closed'});
|
||||||
});
|
});
|
||||||
|
// 未知组件保持不透明只读候选,不猜测私有引用;已知 frame 声明端口缺目标时必须继续等待。
|
||||||
test('declared external reference waits; unsupported component data remains an opaque read-only candidate',()=>{
|
test('declared external reference waits; unsupported component data remains an opaque read-only candidate',()=>{
|
||||||
const preview=createModelPreview(registry);preview.update(head(2));const future={...node('future'),componentId:'plugin.future',dataVersion:99,data:{opaque:{ref:'absent',assetId:'secret'}}};
|
const preview=createModelPreview(registry);preview.update(head(2));const future={...node('future'),componentId:'plugin.future',dataVersion:99,data:{opaque:{ref:'absent',assetId:'secret'}}};
|
||||||
const shown=preview.update(delta([future],1,2));assert.deepEqual(shown.document.elements.get('future'),future);assert.equal(registry.resolve(shown.document.elements.get('future')),'unknown-component');
|
const shown=preview.update(delta([future],1,2));assert.deepEqual(shown.document.elements.get('future'),future);assert.equal(registry.resolve(shown.document.elements.get('future')),'unknown-component');
|
||||||
const delayed={...node('delayed'),componentId:'frame',data:{title:'delayed',text:'',ports:[{nodeId:'absent'}]}};
|
const delayed={...node('delayed'),componentId:'frame',data:{title:'delayed',text:'',ports:[{nodeId:'absent'}]}};
|
||||||
const next=preview.update(delta([delayed],2,2));assert.equal(next.document.elements.size,1);assert.equal(next.waitingElements,1);
|
const next=preview.update(delta([delayed],2,2));assert.equal(next.document.elements.size,1);assert.equal(next.waitingElements,1);
|
||||||
});
|
});
|
||||||
|
// 同批 good+非法几何/身份/owner 或重复 ID 时整体拒绝,不能先发布 good,也不能替换上一合法快照。
|
||||||
test('invalid batches do not replace the last legal prefix or publish a partial batch',()=>{
|
test('invalid batches do not replace the last legal prefix or publish a partial batch',()=>{
|
||||||
for(const bad of [{...node('bad'),geometry:{...node('bad').geometry,width:-1}},{...node('bad'),incarnation:0},{...node('bad'),ownerCanvasId:'foreign'}]){
|
for(const bad of [{...node('bad'),geometry:{...node('bad').geometry,width:-1}},{...node('bad'),incarnation:0},{...node('bad'),ownerCanvasId:'foreign'}]){
|
||||||
const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('good'),bad],2,2)));assert.strictEqual(preview.getSnapshot(),first);assert.equal(first.document.elements.size,0);
|
const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('good'),bad],2,2)));assert.strictEqual(preview.getSnapshot(),first);assert.equal(first.document.elements.size,0);
|
||||||
}
|
}
|
||||||
const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('same'),node('same')],2,2)));assert.strictEqual(preview.getSnapshot(),first);
|
const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('same'),node('same')],2,2)));assert.strictEqual(preview.getSnapshot(),first);
|
||||||
});
|
});
|
||||||
|
// 循环/多根 canvas 拓扑和跨 project 的后续输入拒绝,不能混合成同一个预览。
|
||||||
test('bad topology and version/source switches cannot silently alter a preview',()=>{
|
test('bad topology and version/source switches cannot silently alter a preview',()=>{
|
||||||
for(const topology of [[{id:root,parent:{canvasId:child,entryElementId:'F'}},{id:child,parent:{canvasId:root,entryElementId:'G'}}],[{id:root,parent:null},{id:child,parent:null}]])assert.throws(()=>createModelPreview(registry).update({...head(),canvases:topology}));
|
for(const topology of [[{id:root,parent:{canvasId:child,entryElementId:'F'}},{id:child,parent:{canvasId:root,entryElementId:'G'}}],[{id:root,parent:null},{id:child,parent:null}]])assert.throws(()=>createModelPreview(registry).update({...head(),canvases:topology}));
|
||||||
const preview=createModelPreview(registry);preview.update(head());assert.throws(()=>preview.update({...delta([b],1),projectId:'other'}));
|
const preview=createModelPreview(registry);preview.update(head());assert.throws(()=>preview.update({...delta([b],1),projectId:'other'}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 预览接管元素/头部数据副本,调用者后改不影响已显示内容;最终模型若与前缀不符,报 model_preview_changed。
|
||||||
test('owned preview records do not freeze or retain mutable caller payloads; final completion must match',()=>{
|
test('owned preview records do not freeze or retain mutable caller payloads; final completion must match',()=>{
|
||||||
const preview=createModelPreview(registry),header=structuredClone(head(1)),element=node('one');preview.update(header);const state=preview.update(delta([element],1,1));
|
const preview=createModelPreview(registry),header=structuredClone(head(1)),element=node('one');preview.update(header);const state=preview.update(delta([element],1,1));
|
||||||
element.data.text='changed by caller';header.canvases[0].id='changed';assert.equal(state.document.elements.get('one').data.text,'');assert.equal(state.document.canvases.has(root),true);
|
element.data.text='changed by caller';header.canvases[0].id='changed';assert.equal(state.document.elements.get('one').data.text,'');assert.equal(state.document.canvases.has(root),true);
|
||||||
const final=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[element]);assert.throws(()=>preview.update({status:'ready',document:final,versions}),{code:'model_preview_changed'});
|
const final=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[element]);assert.throws(()=>preview.update({status:'ready',document:final,versions}),{code:'model_preview_changed'});
|
||||||
});
|
});
|
||||||
|
// 后续块重复已发布 ID 属于非法输入,并使投影关闭;之后即使给合法新项也不能继续同一预览。
|
||||||
test('published identities cannot be repeated in later chunks and invalid input closes the projection',()=>{
|
test('published identities cannot be repeated in later chunks and invalid input closes the projection',()=>{
|
||||||
const preview=createModelPreview(registry);preview.update({...head(2),canvases:[{id:root,parent:null}]});preview.update(delta([node('one')],1,2));assert.throws(()=>preview.update(delta([node('one')],2,2)));assert.throws(()=>preview.update(delta([node('two')],2,2)),{code:'model_preview_closed'});
|
const preview=createModelPreview(registry);preview.update({...head(2),canvases:[{id:root,parent:null}]});preview.update(delta([node('one')],1,2));assert.throws(()=>preview.update(delta([node('one')],2,2)));assert.throws(()=>preview.update(delta([node('two')],2,2)),{code:'model_preview_closed'});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// canvasElementCounts 统计收到记录,不把依赖等待者当已发布;证书先防御复制,单 canvas 数量不符在发布前拒绝。
|
||||||
test('scope certificates count received records without treating dependency-blocked members as published',()=>{
|
test('scope certificates count received records without treating dependency-blocked members as published',()=>{
|
||||||
const preview=createModelPreview(registry);const counts={[root]:3,[child]:1};preview.update({...head(),canvasElementCounts:counts});counts[child]=0;
|
const preview=createModelPreview(registry);const counts={[root]:3,[child]:1};preview.update({...head(),canvasElementCounts:counts});counts[child]=0;
|
||||||
const waiting=preview.update(delta([a,edge],2));assert.equal(waiting.document.canvases.has(child),false);assert.equal(waiting.canvasElementCounts[child],1);assert.equal(waiting.waitingElements,2);
|
const waiting=preview.update(delta([a,edge],2));assert.equal(waiting.document.canvases.has(child),false);assert.equal(waiting.canvasElementCounts[child],1);assert.equal(waiting.waitingElements,2);
|
||||||
@@ -50,6 +68,7 @@ test('scope certificates count received records without treating dependency-bloc
|
|||||||
const invalid=createModelPreview(registry),first=invalid.update({...head(1),canvasElementCounts:{[root]:0,[child]:1}});assert.throws(()=>invalid.update(delta([b],1,1)),{code:'invalid_model_coverage'});assert.strictEqual(invalid.getSnapshot(),first);
|
const invalid=createModelPreview(registry),first=invalid.update({...head(1),canvasElementCounts:{[root]:0,[child]:1}});assert.throws(()=>invalid.update(delta([b],1,1)),{code:'invalid_model_coverage'});assert.strictEqual(invalid.getSnapshot(),first);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// elementOwners 清单包含尚未显示的 ID,按捕获归属校验后续记录;外部改清单无效,未声明 ID 的块不能发布。
|
||||||
test('custom loader identity manifests are owned and checked before a receiving batch can publish',()=>{
|
test('custom loader identity manifests are owned and checked before a receiving batch can publish',()=>{
|
||||||
const preview=createModelPreview(registry),elementOwners={F:root,E:root,B:root,A:child};const first=preview.update({...head(),canvasElementCounts:{[root]:3,[child]:1},elementOwners});elementOwners.A=root;assert.equal(first.elementOwners.A,child);assert.deepEqual(first.sourceCanvases,head().canvases);assert.equal(first.document.canvases.has(child),false);assert.ok(Object.isFrozen(first.sourceCanvases));const before=preview.getSnapshot();assert.throws(()=>preview.update(delta([node('not-in-capture')],1)),{code:'invalid_model_identity'});assert.strictEqual(preview.getSnapshot(),before);
|
const preview=createModelPreview(registry),elementOwners={F:root,E:root,B:root,A:child};const first=preview.update({...head(),canvasElementCounts:{[root]:3,[child]:1},elementOwners});elementOwners.A=root;assert.equal(first.elementOwners.A,child);assert.deepEqual(first.sourceCanvases,head().canvases);assert.equal(first.document.canvases.has(child),false);assert.ok(Object.isFrozen(first.sourceCanvases));const before=preview.getSnapshot();assert.throws(()=>preview.update(delta([node('not-in-capture')],1)),{code:'invalid_model_identity'});assert.strictEqual(preview.getSnapshot(),before);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Foundry 模型流 framing、完整校验及 transport/loader 协调测试。流可以先交付只读 delta,完整文档必须等终态记录与正常 EOF。
|
||||||
|
* 快照/序号/覆盖/身份清单绑定所有后续记录,错误尾部保留可读前缀但不能打开为权威基线。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||||
@@ -5,28 +9,39 @@ import {decodeModelStream,modelStreamType} from '../../src/lib/canvas-service/mo
|
|||||||
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.ts';
|
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.ts';
|
||||||
import {createFoundryModelLoader} from '../../src/lib/canvas-service/model-loader.ts';
|
import {createFoundryModelLoader} from '../../src/lib/canvas-service/model-loader.ts';
|
||||||
import {createScopedLoader} from '../../src/lib/canvas-runtime/loader.ts';
|
import {createScopedLoader} from '../../src/lib/canvas-runtime/loader.ts';
|
||||||
|
// 固定 UUID 作用域,满足 transport 与模型解析的身份约束。
|
||||||
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
|
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
|
||||||
|
// 内置定义和独立信号工厂,避免取消跨用例复用。
|
||||||
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
|
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
|
||||||
|
// 含中文及 emoji 的合法节点样本,验证逐字节传输时 UTF-8 边界处理。
|
||||||
const node={id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'中文',text:'🙂'},geometry:{x:0,y:0,width:100,height:80,rotation:0}};
|
const node={id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'中文',text:'🙂'},geometry:{x:0,y:0,width:100,height:80,rotation:0}};
|
||||||
|
// 每次新建 header/elements/complete 记录,快照 token 相同、序号连续且声明一个元素;便于逐字段篡改。
|
||||||
const records=()=>[{streamVersion:1,snapshotToken:'snapshot-a',sequence:0,kind:'header',documentFormatVersion:1,projectId:root,canvases:[{id:root,parent:null}],versions:{[root]:3},tombstones:{old:2},removedCanvasIds:[],elementCount:1},{streamVersion:1,snapshotToken:'snapshot-a',sequence:1,kind:'elements',elements:[structuredClone(node)]},{streamVersion:1,snapshotToken:'snapshot-a',sequence:2,kind:'complete',elementCount:1}];
|
const records=()=>[{streamVersion:1,snapshotToken:'snapshot-a',sequence:0,kind:'header',documentFormatVersion:1,projectId:root,canvases:[{id:root,parent:null}],versions:{[root]:3},tombstones:{old:2},removedCanvasIds:[],elementCount:1},{streamVersion:1,snapshotToken:'snapshot-a',sequence:1,kind:'elements',elements:[structuredClone(node)]},{streamVersion:1,snapshotToken:'snapshot-a',sequence:2,kind:'complete',elementCount:1}];
|
||||||
|
// 编码为带末尾换行的 UTF-8 NDJSON;byteLength 断言以真实传输字节为准。
|
||||||
const encode=rows=>new TextEncoder().encode(rows.map(row=>JSON.stringify(row)).join('\n')+'\n');
|
const encode=rows=>new TextEncoder().encode(rows.map(row=>JSON.stringify(row)).join('\n')+'\n');
|
||||||
|
// 单批字节异步源,不模拟网络切片,逐字节场景使用另一个 split generator。
|
||||||
async function* source(rows){yield encode(rows);}
|
async function* source(rows){yield encode(rows);}
|
||||||
|
// 排空异步迭代器到数组,让尾部或 EOF 校验错误也传播到测试断言。
|
||||||
const collect=async iterable=>{const result=[];for await(const item of iterable)result.push(item);return result;};
|
const collect=async iterable=>{const result=[];for await(const item of iterable)result.push(item);return result;};
|
||||||
|
// canonical 完整项目查询作用域,交给通用 scoped loader 检查权限和查询绑定。
|
||||||
const scope={accountId:account,workspaceId:workspace,projectId:null,canvasId:root,authEpoch:1,navigationGeneration:1,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry'};
|
const scope={accountId:account,workspaceId:workspace,projectId:null,canvasId:root,authEpoch:1,navigationGeneration:1,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry'};
|
||||||
|
|
||||||
|
// 逐字节传入 Unicode/换行,依次输出接收头、元素 delta、最终 ready;正文/墓碑及总 byteLength 完整,记录冻结。
|
||||||
test('stream decoder frames split UTF8/newlines, emits deltas and validates one final complete model',async()=>{
|
test('stream decoder frames split UTF8/newlines, emits deltas and validates one final complete model',async()=>{
|
||||||
const bytes=encode(records());async function* split(){for(const byte of bytes)yield Uint8Array.of(byte);}
|
const bytes=encode(records());/* 每批只发一个字节,主动拆开多字节 UTF-8 与记录边界。 */ async function* split(){for(const byte of bytes)yield Uint8Array.of(byte);}
|
||||||
const chunks=await collect(decodeModelStream(split(),root,registry,signal()));
|
const chunks=await collect(decodeModelStream(split(),root,registry,signal()));
|
||||||
assert.deepEqual(chunks.map(chunk=>[chunk.sequence,chunk.complete,chunk.value.status]),[[0,false,'receiving'],[1,false,'receiving'],[2,true,'ready']]);
|
assert.deepEqual(chunks.map(chunk=>[chunk.sequence,chunk.complete,chunk.value.status]),[[0,false,'receiving'],[1,false,'receiving'],[2,true,'ready']]);
|
||||||
assert.equal(chunks[1].value.elements[0].data.text,'🙂');assert.equal(chunks[2].value.document.elements.get('a').data.title,'中文');assert.equal(chunks[2].value.document.tombstones.get('old'),2);assert.equal(chunks.reduce((sum,chunk)=>sum+chunk.byteLength,0),bytes.length);assert.ok(Object.isFrozen(chunks[1].value.elements[0]));
|
assert.equal(chunks[1].value.elements[0].data.text,'🙂');assert.equal(chunks[2].value.document.elements.get('a').data.title,'中文');assert.equal(chunks[2].value.document.tombstones.get('old'),2);assert.equal(chunks.reduce((sum,chunk)=>sum+chunk.byteLength,0),bytes.length);assert.ok(Object.isFrozen(chunks[1].value.elements[0]));
|
||||||
});
|
});
|
||||||
|
// 快照切换、序号缺口、计数不符、重复 ID、错误 owner/几何/版本/拓扑、缺少结束及缺末换行都不能完成,超大行按流预算拒绝。
|
||||||
test('snapshot switches, sequence gaps, duplicate IDs, wrong counts, late records and invalid topology cannot finish',async()=>{
|
test('snapshot switches, sequence gaps, duplicate IDs, wrong counts, late records and invalid topology cannot finish',async()=>{
|
||||||
const cases=[rows=>rows[1].snapshotToken='other',rows=>rows[1].sequence=2,rows=>rows[0].elementCount=2,rows=>rows[2].elementCount=2,rows=>rows.push({...rows[2],sequence:3}),rows=>rows[1].elements.push(rows[1].elements[0]),rows=>rows[1].elements[0].ownerCanvasId=workspace,rows=>rows[1].elements[0].geometry.width=-1,rows=>rows[0].versions={},rows=>rows[0].canvases[0].parent={canvasId:root,entryElementId:'a'}];
|
const cases=[rows=>rows[1].snapshotToken='other',rows=>rows[1].sequence=2,rows=>rows[0].elementCount=2,rows=>rows[2].elementCount=2,rows=>rows.push({...rows[2],sequence:3}),rows=>rows[1].elements.push(rows[1].elements[0]),rows=>rows[1].elements[0].ownerCanvasId=workspace,rows=>rows[1].elements[0].geometry.width=-1,rows=>rows[0].versions={},rows=>rows[0].canvases[0].parent={canvasId:root,entryElementId:'a'}];
|
||||||
for(const mutate of cases){const rows=records();mutate(rows);await assert.rejects(()=>collect(decodeModelStream(source(rows),root,registry,signal())));}
|
for(const mutate of cases){const rows=records();mutate(rows);await assert.rejects(()=>collect(decodeModelStream(source(rows),root,registry,signal())));}
|
||||||
for(const rows of [[],records().slice(0,1),records().slice(0,2)])await assert.rejects(()=>collect(decodeModelStream(source(rows),root,registry,signal())));
|
for(const rows of [[],records().slice(0,1),records().slice(0,2)])await assert.rejects(()=>collect(decodeModelStream(source(rows),root,registry,signal())));
|
||||||
async function* truncated(){yield encode(records()).slice(0,-1);}await assert.rejects(()=>collect(decodeModelStream(truncated(),root,registry,signal())),{code:'incomplete_model_stream'});
|
/* 删除文件末换行,验证终态记录也不能绕过 framing 完整性。 */ async function* truncated(){yield encode(records()).slice(0,-1);}await assert.rejects(()=>collect(decodeModelStream(truncated(),root,registry,signal())),{code:'incomplete_model_stream'});
|
||||||
async function* huge(){yield new Uint8Array(8*1024*1024+1).fill(65);}await assert.rejects(()=>collect(decodeModelStream(huge(),root,registry,signal())),{code:'model_stream_budget'});
|
/* 单行超过 8 MiB,没有换行,必须在无限积累前触发预算。 */ async function* huge(){yield new Uint8Array(8*1024*1024+1).fill(65);}await assert.rejects(()=>collect(decodeModelStream(huge(),root,registry,signal())),{code:'model_stream_budget'});
|
||||||
});
|
});
|
||||||
|
// transport 以 Accept 协商流并交出 ReadableStream;尾部 gate 前 loader 已看到首个元素 delta,放行后才 complete。
|
||||||
test('Foundry transport negotiates streaming without buffering; coordinator retains first delta before gated tail',async()=>{
|
test('Foundry transport negotiates streaming without buffering; coordinator retains first delta before gated tail',async()=>{
|
||||||
let release,opened;const gate=new Promise(resolve=>release=resolve),started=new Promise(resolve=>opened=resolve);let requested;
|
let release,opened;const gate=new Promise(resolve=>release=resolve),started=new Promise(resolve=>opened=resolve);let requested;
|
||||||
const rows=records(),stream=new ReadableStream({async start(controller){controller.enqueue(encode(rows.slice(0,2)));opened();await gate;controller.enqueue(encode(rows.slice(2)));controller.close();}});
|
const rows=records(),stream=new ReadableStream({async start(controller){controller.enqueue(encode(rows.slice(0,2)));opened();await gate;controller.enqueue(encode(rows.slice(2)));controller.close();}});
|
||||||
@@ -38,29 +53,34 @@ test('Foundry transport negotiates streaming without buffering; coordinator reta
|
|||||||
release();const done=await handle.ready;assert.equal(done.status,'complete');assert.equal(done.chunks.at(-1).value.document.elements.size,1);
|
release();const done=await handle.ready;assert.equal(done.status,'complete');assert.equal(done.chunks.at(-1).value.document.elements.size,1);
|
||||||
}finally{release();handle.release();loader.close();}
|
}finally{release();handle.release();loader.close();}
|
||||||
});
|
});
|
||||||
|
// 坏 JSON 尾部使 loader failed-with-partial,保留两个 receiving 块,不能输出权威模型。
|
||||||
test('malformed tails retain read deltas without yielding an authoritative model',async()=>{
|
test('malformed tails retain read deltas without yielding an authoritative model',async()=>{
|
||||||
async function* broken(){yield encode(records().slice(0,2));yield new TextEncoder().encode('{broken}\n');}
|
/* 合法头/元素后追加坏 JSON,保留可读前缀并拒绝最终模型。 */ async function* broken(){yield encode(records().slice(0,2));yield new TextEncoder().encode('{broken}\n');}
|
||||||
const states=[],loader=createScopedLoader({load:createFoundryModelLoader({workspaceId:workspace,registry,transport:async()=>({status:200,body:null,stream:broken()})}),current:()=>true});const handle=loader.acquire(scope,{onUpdate:value=>states.push(value)});
|
const states=[],loader=createScopedLoader({load:createFoundryModelLoader({workspaceId:workspace,registry,transport:async()=>({status:200,body:null,stream:broken()})}),current:()=>true});const handle=loader.acquire(scope,{onUpdate:value=>states.push(value)});
|
||||||
try{await assert.rejects(handle.ready);assert.equal(handle.getSnapshot().status,'failed-with-partial');assert.equal(handle.getSnapshot().chunks.length,2);assert.equal(handle.getSnapshot().chunks.at(-1).value.status,'receiving');}finally{handle.release();loader.close();}
|
try{await assert.rejects(handle.ready);assert.equal(handle.getSnapshot().status,'failed-with-partial');assert.equal(handle.getSnapshot().chunks.length,2);assert.equal(handle.getSnapshot().chunks.at(-1).value.status,'receiving');}finally{handle.release();loader.close();}
|
||||||
});
|
});
|
||||||
|
// complete 记录已到但流未结束时 ready 仍未解决;随后垃圾尾行使读取失败,不能先打开基线再反悔。
|
||||||
test('terminal record still requires EOF; trailing garbage rejects rather than opening a baseline',async()=>{
|
test('terminal record still requires EOF; trailing garbage rejects rather than opening a baseline',async()=>{
|
||||||
const gate={},rows=records();let release;gate.promise=new Promise(resolve=>release=resolve);
|
const gate={},rows=records();let release;gate.promise=new Promise(resolve=>release=resolve);
|
||||||
async function* trailing(){yield encode(rows);await gate.promise;yield new TextEncoder().encode('x\n');}
|
/* complete 之后仍阻塞迭代器 EOF,之后追加垃圾,检查 ready 不会过早成功。 */ async function* trailing(){yield encode(rows);await gate.promise;yield new TextEncoder().encode('x\n');}
|
||||||
const loader=createScopedLoader({load:createFoundryModelLoader({workspaceId:workspace,registry,transport:async()=>({status:200,body:null,stream:trailing()})}),current:()=>true}),handle=loader.acquire(scope);let ready=false;handle.ready.then(()=>ready=true,()=>{});
|
const loader=createScopedLoader({load:createFoundryModelLoader({workspaceId:workspace,registry,transport:async()=>({status:200,body:null,stream:trailing()})}),current:()=>true}),handle=loader.acquire(scope);let ready=false;handle.ready.then(()=>ready=true,()=>{});
|
||||||
try{while(handle.getSnapshot().chunks.length<2)await new Promise(resolve=>setImmediate(resolve));assert.equal(ready,false);release();await assert.rejects(handle.ready);assert.equal(handle.getSnapshot().status,'failed-with-partial');}finally{release();handle.release();loader.close();}
|
try{while(handle.getSnapshot().chunks.length<2)await new Promise(resolve=>setImmediate(resolve));assert.equal(ready,false);release();await assert.rejects(handle.ready);assert.equal(handle.getSnapshot().status,'failed-with-partial');}finally{release();handle.release();loader.close();}
|
||||||
});
|
});
|
||||||
|
// abort 取消阻塞的 body reader;身份失效也拒绝下一次读取并取消底层流,分别核对 cancel 次数。
|
||||||
test('stream transport fences changed identity and abort cancels a blocked body reader',async()=>{
|
test('stream transport fences changed identity and abort cancels a blocked body reader',async()=>{
|
||||||
let current=true,cancelled=0;const abort=new AbortController();const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async()=>new Response(new ReadableStream({cancel(){cancelled++;}}),{headers:{'Content-Type':modelStreamType}})});
|
let current=true,cancelled=0;const abort=new AbortController();const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async()=>new Response(new ReadableStream({cancel(){cancelled++;}}),{headers:{'Content-Type':modelStreamType}})});
|
||||||
const reply=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1',signal:abort.signal});const iterator=reply.stream[Symbol.asyncIterator]();const pending=iterator.next();await Promise.resolve();abort.abort();await assert.rejects(pending);assert.equal(cancelled,1);
|
const reply=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1',signal:abort.signal});const iterator=reply.stream[Symbol.asyncIterator]();const pending=iterator.next();await Promise.resolve();abort.abort();await assert.rejects(pending);assert.equal(cancelled,1);
|
||||||
const other=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1'});current=false;await assert.rejects(()=>other.stream[Symbol.asyncIterator]().next(),{code:'identity_changed'});assert.equal(cancelled,2);
|
const other=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1'});current=false;await assert.rejects(()=>other.stream[Symbol.asyncIterator]().next(),{code:'identity_changed'});assert.equal(cancelled,2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 可选 canvasElementCounts 保持旧流兼容;非法清单或后续元素超过单 canvas 声明在发布前拒绝。
|
||||||
test('optional snapshot scope counts preserve legacy streams and reject invalid or exceeded coverage before publication',async()=>{
|
test('optional snapshot scope counts preserve legacy streams and reject invalid or exceeded coverage before publication',async()=>{
|
||||||
const rows=records();rows[0].canvasElementCounts={[root]:1};const seen=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(seen[0].value.canvasElementCounts,{[root]:1});assert.equal(Object.isFrozen(seen[0].value.canvasElementCounts),true);
|
const rows=records();rows[0].canvasElementCounts={[root]:1};const seen=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(seen[0].value.canvasElementCounts,{[root]:1});assert.equal(Object.isFrozen(seen[0].value.canvasElementCounts),true);
|
||||||
for(const manifest of [null,[],{}, {[root]:-1},{[root]:.5},{[root]:2},{[root]:1,[workspace]:0}]){const broken=records();broken[0].canvasElementCounts=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_coverage'});}
|
for(const manifest of [null,[],{}, {[root]:-1},{[root]:.5},{[root]:2},{[root]:1,[workspace]:0}]){const broken=records();broken[0].canvasElementCounts=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_coverage'});}
|
||||||
const broken=records();broken[0].canvases.push({id:workspace,parent:{canvasId:root,entryElementId:'frame'}});broken[0].versions[workspace]=1;broken[0].canvasElementCounts={[root]:0,[workspace]:1};const iterator=decodeModelStream(source(broken),root,registry,signal())[Symbol.asyncIterator]();assert.equal((await iterator.next()).value.value.receivedElements,0);await assert.rejects(iterator.next(),{code:'invalid_model_coverage'});
|
const broken=records();broken[0].canvases.push({id:workspace,parent:{canvasId:root,entryElementId:'frame'}});broken[0].versions[workspace]=1;broken[0].canvasElementCounts={[root]:0,[workspace]:1};const iterator=decodeModelStream(source(broken),root,registry,signal())[Symbol.asyncIterator]();assert.equal((await iterator.next()).value.value.receivedElements,0);await assert.rejects(iterator.next(),{code:'invalid_model_coverage'});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// elementOwners 声明尚未加载的 ID 与 owner;非法清单或后续未捕获 ID 在发布前拒绝,清单自身冻结。
|
||||||
test('identity manifest covers unloaded IDs and binds each received record to its captured owner',async()=>{
|
test('identity manifest covers unloaded IDs and binds each received record to its captured owner',async()=>{
|
||||||
const rows=records();rows[0].canvasElementCounts={[root]:1};rows[0].elementOwners={a:root};const chunks=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(chunks[0].value.elementOwners,{a:root});assert.ok(Object.isFrozen(chunks[0].value.elementOwners));
|
const rows=records();rows[0].canvasElementCounts={[root]:1};rows[0].elementOwners={a:root};const chunks=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(chunks[0].value.elementOwners,{a:root});assert.ok(Object.isFrozen(chunks[0].value.elementOwners));
|
||||||
for(const manifest of [null,[],{}, {other:workspace},{a:root,other:root}]){const broken=structuredClone(rows);broken[0].elementOwners=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_identity'});}
|
for(const manifest of [null,[],{}, {other:workspace},{a:root,other:root}]){const broken=structuredClone(rows);broken[0].elementOwners=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_identity'});}
|
||||||
|
|||||||
@@ -1,46 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 本地文件盘点、保留期和回收的引擎测试。是否孤立由所有已知引用及保守覆盖判断,不能仅看资源登记表。
|
||||||
|
* 内存端口声明 atomic-v1 文件删除能力,随镜像保存模拟文件增加/删除;不验证真实文件系统的原子性。
|
||||||
|
* 清理不改变业务文档和历史,退休身份则必须持久保留,防止稍后误用同 ID 的已删除字节。
|
||||||
|
*/
|
||||||
import {test} from 'node:test';
|
import {test} from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||||
import {orphanCoverage} from '../../src/lib/canvas-runtime/orphan-files.ts';
|
import {orphanCoverage} from '../../src/lib/canvas-runtime/orphan-files.ts';
|
||||||
|
// 图片目标默认没有 assetId,各场景自行添加引用;文件 Map 可包含尚未登记但仍被元素引用的字节。
|
||||||
const registry=createRegistry(builtinComponents()),scope={accountId:'a',workspaceId:'w',projectId:'p'},node=(id,data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:'C',componentId:'image',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...data}}),original=createDocument('p',[{id:'C',parent:null}],[node('A')]);
|
const registry=createRegistry(builtinComponents()),scope={accountId:'a',workspaceId:'w',projectId:'p'},node=(id,data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:'C',componentId:'image',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...data}}),original=createDocument('p',[{id:'C',parent:null}],[node('A')]);
|
||||||
|
// 用可控 Promise 挂起读字节,模拟显示借用、取消和 Root 变化与回收扫描交错。
|
||||||
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
|
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
|
||||||
|
// 将文件 Map、业务模型、内存镜像和服务替身组合,保留批次与请求记录用于检查回收是否越界。
|
||||||
function fixture(files=new Map(),document=original){
|
function fixture(files=new Map(),document=original){
|
||||||
let image,owner,next=0,server=document,version=1,now=1000;const batches=[],requests=[],receipts=new Map();
|
let image,owner,next=0,server=document,version=1,now=1000;const batches=[],requests=[],receipts=new Map();
|
||||||
const storage={fileDeletion:'atomic-v1',async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;},async recover(){return image?structuredClone(image):null;},async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};},async readFile(leader,id){assert.equal(leader,owner);return files.get(id);},async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>a<b?-1:a>b?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}};
|
// commit 在同一替身调用中处理文件和镜像;scanFiles 必须匹配调用者捕获的 rootRevision,并按文件 ID 分页。
|
||||||
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}),async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};},async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},canonical:request=>receipts.get(request.id)};
|
const storage={fileDeletion:'atomic-v1',
|
||||||
|
// 只允许当前引擎持有文件仓库;释放时同时清除 owner 和 active 标记。
|
||||||
|
async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;},
|
||||||
|
// 返回克隆恢复镜像,文件字节由独立 files 仓库保留。
|
||||||
|
async recover(){return image?structuredClone(image):null;},
|
||||||
|
// 校验 owner,按批次写入/删除字节后保存镜像;记录整个 batch 供清理断言观察。
|
||||||
|
async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};},
|
||||||
|
// 读取本地 Blob;用例可包装这个方法在真实返回前挂起。
|
||||||
|
async readFile(leader,id){assert.equal(leader,owner);return files.get(id);},
|
||||||
|
// 只在指定持久化修订上盘点;排序后取最多 limit 项,用 nextAfter 表示尚有后页。
|
||||||
|
async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>a<b?-1:a>b?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}};
|
||||||
|
// 普通文档编辑通过文档端口发送并处理规范回执;清理本身不应该产生任何网络文档请求。
|
||||||
|
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}),
|
||||||
|
// 应用普通文档修改并保存该请求的规范模型;资源清理场景通过 requests 检查未误入此路径。
|
||||||
|
async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};},
|
||||||
|
// 只返回此替身保存过的请求结果;没有记录则保持 unresolved。
|
||||||
|
async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},
|
||||||
|
// 根据请求取 ACK 基线,供无关编辑的正常同步使用。
|
||||||
|
canonical:request=>receipts.get(request.id)};
|
||||||
|
// 复用同一文件仓库与存储镜像以支持重开;advance 只推进保留期墙上时间,网络重试仍使用真实定时器。
|
||||||
return{files,batches,requests,storage,advance:ms=>{now+=ms;},image:()=>image,open:()=>openProjectEngine({scope,registry,document,versions:new Map([['C',1]]),storage,persistence:port,createId:()=>`id-${++next}`,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}})};
|
return{files,batches,requests,storage,advance:ms=>{now+=ms;},image:()=>image,open:()=>openProjectEngine({scope,registry,document,versions:new Map([['C',1]]),storage,persistence:port,createId:()=>`id-${++next}`,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}})};
|
||||||
}
|
}
|
||||||
|
// 只读盘点按 limit 分页且保持 Root 引用;未登记但被元素引用的文件受保护,只有无引用文件经标记和保留期后删除,文档/history 不变。
|
||||||
test('inventory is bounded and read-only; unregistered but referenced files stay protected',async()=>{
|
test('inventory is bounded and read-only; unregistered but referenced files stay protected',async()=>{
|
||||||
const doc=createDocument('p',[{id:'C',parent:null}],[node('A',{assetId:'used'})]),f=fixture(new Map([['old',new Blob(['old'])],['used',new Blob(['used'])]]),doc),engine=await f.open();try{await engine.persist();const root=engine.getSnapshot(),first=await engine.inventoryLocalFiles({limit:1});assert.equal(engine.getSnapshot(),root);assert.equal(first.files[0].id,'old');assert.equal(first.files[0].ownership,'unregistered');assert.equal(first.nextAfter,'old');const second=await engine.inventoryLocalFiles({after:first.nextAfter,limit:1});assert.equal(second.files[0].id,'used');assert.ok(second.files[0].pins.some(owner=>owner.startsWith('element:')));assert.equal(second.nextAfter,undefined);assert.equal(f.requests.length,0);
|
const doc=createDocument('p',[{id:'C',parent:null}],[node('A',{assetId:'used'})]),f=fixture(new Map([['old',new Blob(['old'])],['used',new Blob(['used'])]]),doc),engine=await f.open();try{await engine.persist();const root=engine.getSnapshot(),first=await engine.inventoryLocalFiles({limit:1});assert.equal(engine.getSnapshot(),root);assert.equal(first.files[0].id,'old');assert.equal(first.files[0].ownership,'unregistered');assert.equal(first.nextAfter,'old');const second=await engine.inventoryLocalFiles({after:first.nextAfter,limit:1});assert.equal(second.files[0].id,'used');assert.ok(second.files[0].pins.some(owner=>owner.startsWith('element:')));assert.equal(second.nextAfter,undefined);assert.equal(f.requests.length,0);
|
||||||
const marked=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(marked.markedIds,['old']);assert.deepEqual(marked.purgedIds,[]);assert.equal(f.image().formatVersion,5);f.advance(100);const result=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(result.purgedIds,['old']);assert.equal(f.files.has('old'),false);assert.equal(f.files.has('used'),true);assert.equal(engine.getSnapshot().document,root.document);assert.equal(engine.getSnapshot().history,root.history);assert.equal(f.requests.length,0);decodeRecovery(f.image(),registry);
|
const marked=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(marked.markedIds,['old']);assert.deepEqual(marked.purgedIds,[]);assert.equal(f.image().formatVersion,5);f.advance(100);const result=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(result.purgedIds,['old']);assert.equal(f.files.has('old'),false);assert.equal(f.files.has('used'),true);assert.equal(engine.getSnapshot().document,root.document);assert.equal(engine.getSnapshot().history,root.history);assert.equal(f.requests.length,0);decodeRecovery(f.image(),registry);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 退休文件及 v5 镜像重开后仍禁止显示或新增引用,也不能通过清空 orphanFiles 抹去退休证据;无关编辑继续可用,静默降级镜像被拒绝。
|
||||||
test('retired orphan identity and v5 recovery survive reopening and reject new references or silent downgrade',async()=>{
|
test('retired orphan identity and v5 recovery survive reopening and reject new references or silent downgrade',async()=>{
|
||||||
const f=fixture(new Map([['old',new Blob(['old'])]]));let engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:0});const file=engine.getSnapshot().runtime.orphanFiles.get('old');assert.ok(file.retirement.purgedAt!==undefined);for(const formatVersion of [1,2,3,4])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);await engine.close();engine=await f.open();await assert.rejects(engine.displayResource('old'),/local_file_retired/);assert.equal(engine.editor.dispatch({type:'element.create',element:node('bad',{assetId:'old'})}).code,'local_file_retired');assert.equal(engine.editor.updateRuntime(previous=>({...previous,orphanFiles:new Map()})).code,'local_file_retired');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.files.has('old'),false);
|
const f=fixture(new Map([['old',new Blob(['old'])]]));let engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:0});const file=engine.getSnapshot().runtime.orphanFiles.get('old');assert.ok(file.retirement.purgedAt!==undefined);for(const formatVersion of [1,2,3,4])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);await engine.close();engine=await f.open();await assert.rejects(engine.displayResource('old'),/local_file_retired/);assert.equal(engine.editor.dispatch({type:'element.create',element:node('bad',{assetId:'old'})}).code,'local_file_retired');assert.equal(engine.editor.updateRuntime(previous=>({...previous,orphanFiles:new Map()})).code,'local_file_retired');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.files.has('old'),false);
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 文件大小与 MIME 相同也可能换了字节;digest 改变必须重置无引用起点,重新等待完整保留期才能删除。
|
||||||
test('changed orphan bytes reset the retention interval even when size and MIME stay equal',async()=>{
|
test('changed orphan bytes reset the retention interval even when size and MIME stay equal',async()=>{
|
||||||
const f=fixture(new Map([['old',new Blob(['aa'])]])),engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:100});const digest=engine.getSnapshot().runtime.orphanFiles.get('old').digest;f.advance(100);f.files.set('old',new Blob(['bb']));const changed=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(changed.changedIds,['old']);assert.deepEqual(changed.purgedIds,[]);const record=engine.getSnapshot().runtime.orphanFiles.get('old');assert.notEqual(record.digest,digest);assert.equal(record.retirement.unreferencedAt,1100);f.advance(100);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,['old']);}finally{await engine.close();}
|
const f=fixture(new Map([['old',new Blob(['aa'])]])),engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:100});const digest=engine.getSnapshot().runtime.orphanFiles.get('old').digest;f.advance(100);f.files.set('old',new Blob(['bb']));const changed=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(changed.changedIds,['old']);assert.deepEqual(changed.purgedIds,[]);const record=engine.getSnapshot().runtime.orphanFiles.get('old');assert.notEqual(record.digest,digest);assert.equal(record.retirement.unreferencedAt,1100);f.advance(100);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,['old']);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 显示读取一开始即借用并清除软退休标记,不必等 URL 返回;释放后可重新计时,显式 resourcePins 再次阻止删除。
|
||||||
test('display borrowing and explicit owners reset soft markers before collection',async()=>{
|
test('display borrowing and explicit owners reset soft markers before collection',async()=>{
|
||||||
const f=fixture(new Map([['old',new Blob(['data'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const opening=engine.displayResource('old');await entered.promise;assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await opening;handle.release();await engine.collectOrphanFiles({retentionMs:100});engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['export',['old']]])}));assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);}finally{gate.resolve();await engine.close();}
|
const f=fixture(new Map([['old',new Blob(['data'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const opening=engine.displayResource('old');await entered.promise;assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await opening;handle.release();await engine.collectOrphanFiles({retentionMs:100});engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['export',['old']]])}));assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 哈希字节预算耗尽要保留 nextAfter 继续扫描;读字节期间 Root 改变会使验证过期,此轮不能删除任何文件。
|
||||||
test('hash budgets preserve pagination progress and a changed Root invalidates read-only verification',async()=>{
|
test('hash budgets preserve pagination progress and a changed Root invalidates read-only verification',async()=>{
|
||||||
const f=fixture(new Map([['a',new Blob(['a'])],['b',new Blob(['bb'])],['c',new Blob(['c'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{const first=await engine.collectOrphanFiles({retentionMs:100,maxHashBytes:1});assert.deepEqual(first.markedIds,['a']);assert.equal(first.nextAfter,'a');assert.equal(first.hashBudgetExhausted,true);assert.equal(first.scanFinished,false);const second=await engine.collectOrphanFiles({retentionMs:100,after:first.nextAfter,maxHashBytes:3});assert.deepEqual(second.markedIds,['b','c']);assert.equal(second.scanFinished,true);
|
const f=fixture(new Map([['a',new Blob(['a'])],['b',new Blob(['bb'])],['c',new Blob(['c'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{const first=await engine.collectOrphanFiles({retentionMs:100,maxHashBytes:1});assert.deepEqual(first.markedIds,['a']);assert.equal(first.nextAfter,'a');assert.equal(first.hashBudgetExhausted,true);assert.equal(first.scanFinished,false);const second=await engine.collectOrphanFiles({retentionMs:100,after:first.nextAfter,maxHashBytes:3});assert.deepEqual(second.markedIds,['b','c']);assert.equal(second.scanFinished,true);
|
||||||
f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const collecting=engine.collectOrphanFiles({retentionMs:0});await entered.promise;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'changed while hashing'}}).status,'applied');gate.resolve();await assert.rejects(collecting,/stale_file_inventory/);assert.equal(f.files.size,3);
|
f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const collecting=engine.collectOrphanFiles({retentionMs:0});await entered.promise;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'changed while hashing'}}).status,'applied');gate.resolve();await assert.rejects(collecting,/stale_file_inventory/);assert.equal(f.files.size,3);
|
||||||
}finally{gate.resolve();await engine.close();}
|
}finally{gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
// abort 后迟到字节不得发布盘点结果;删除批次保存失败时保留原清理切点及资源访问屏障,persist 重试完成同一次删除。
|
||||||
test('cancelling verification never publishes late byte results and failures retain the original purge cut',async()=>{
|
test('cancelling verification never publishes late byte results and failures retain the original purge cut',async()=>{
|
||||||
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile,cancel=new AbortController();let fail=false;const commit=f.storage.commit;try{f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const task=engine.collectOrphanFiles({retentionMs:0,signal:cancel.signal});await entered.promise;cancel.abort();await assert.rejects(task,/file_inventory_aborted/);gate.resolve();await Promise.resolve();assert.equal(engine.getSnapshot().runtime.orphanFiles?.size??0,0);f.storage.readFile=read;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectOrphanFiles({retentionMs:0}),/disk failure/);assert.ok(f.files.has('old'));await assert.rejects(engine.displayResource('old'),/resource_collection_in_progress/);fail=false;await engine.persist();assert.equal(f.files.has('old'),false);assert.ok(decodeRecovery(f.image(),registry).runtime.orphanFiles.get('old').retirement.purgedAt!==undefined);
|
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile,cancel=new AbortController();let fail=false;const commit=f.storage.commit;try{f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const task=engine.collectOrphanFiles({retentionMs:0,signal:cancel.signal});await entered.promise;cancel.abort();await assert.rejects(task,/file_inventory_aborted/);gate.resolve();await Promise.resolve();assert.equal(engine.getSnapshot().runtime.orphanFiles?.size??0,0);f.storage.readFile=read;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectOrphanFiles({retentionMs:0}),/disk failure/);assert.ok(f.files.has('old'));await assert.rejects(engine.displayResource('old'),/resource_collection_in_progress/);fail=false;await engine.persist();assert.equal(f.files.has('old'),false);assert.ok(decodeRecovery(f.image(),registry).runtime.orphanFiles.get('old').retirement.purgedAt!==undefined);
|
||||||
}finally{fail=false;gate.resolve();await engine.close();}
|
}finally{fail=false;gate.resolve();await engine.close();}
|
||||||
});
|
});
|
||||||
|
// 运行任务、恢复审计、未完成导入和复制准入都是不透明生产者;即使已知资源表为空,引用覆盖仍不完整,不能据此回收。
|
||||||
test('opaque producers block orphan collection even when the known local resource map is empty',async()=>{
|
test('opaque producers block orphan collection even when the known local resource map is empty',async()=>{
|
||||||
const f=fixture(),engine=await f.open();try{const root=engine.getSnapshot(),coverage=runtime=>orphanCoverage({...root,runtime},registry,new Map(),new Map());assert.equal(coverage({...root.runtime,runs:new Map([['run',{id:'run'}]])}).complete,false);assert.ok(coverage({...root.runtime,recoveries:new Map([['audit',{}]])}).blockers.includes('recovery_audits'));assert.ok(coverage({...root.runtime,imports:new Map([['job',{id:'job',status:'paused',source:{fileId:'source'}}]])}).blockers.includes('unfinished_imports'));assert.ok(orphanCoverage(root,registry,new Map(),new Map([['copy-admission',[]]])).blockers.includes('copy_admission'));
|
const f=fixture(),engine=await f.open();try{const root=engine.getSnapshot(),coverage=runtime=>orphanCoverage({...root,runtime},registry,new Map(),new Map());assert.equal(coverage({...root.runtime,runs:new Map([['run',{id:'run'}]])}).complete,false);assert.ok(coverage({...root.runtime,recoveries:new Map([['audit',{}]])}).blockers.includes('recovery_audits'));assert.ok(coverage({...root.runtime,imports:new Map([['job',{id:'job',status:'paused',source:{fileId:'source'}}]])}).blockers.includes('unfinished_imports'));assert.ok(orphanCoverage(root,registry,new Map(),new Map([['copy-admission',[]]])).blockers.includes('copy_admission'));
|
||||||
}finally{await engine.close();}
|
}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 复制准入在 ImportJob 出现前已构成保护;即使调用带已取消 signal,也保守清除软退休标记,下一次扫描从当前时间重新计时。
|
||||||
test('a cancelled copy admission resets orphan retention before any ImportJob is published',async()=>{
|
test('a cancelled copy admission resets orphan retention before any ImportJob is published',async()=>{
|
||||||
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),cancel=new AbortController();try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);cancel.abort();await assert.rejects(engine.imports.duplicate([{id:'A',incarnation:1}],{signal:cancel.signal}),/copy_aborted/);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,[]);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement.unreferencedAt,1100);}finally{await engine.close();}
|
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),cancel=new AbortController();try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);cancel.abort();await assert.rejects(engine.imports.duplicate([{id:'A',incarnation:1}],{signal:cancel.signal}),/copy_aborted/);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,[]);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement.unreferencedAt,1100);}finally{await engine.close();}
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user