1 Commits

51 changed files with 97 additions and 1404 deletions
-2
View File
@@ -1,4 +1,2 @@
# 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
-7
View File
@@ -1,15 +1,8 @@
# 安装依赖不入库;依赖版本由包清单与 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/
-70
View File
@@ -142,73 +142,3 @@ 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` 的公开项目标识与服务端源站说明写在模板内;实际凭据不应写进示例或公开构建变量。
-3
View File
@@ -80,9 +80,6 @@ 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");
// 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。 // 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。
-66
View File
@@ -1,124 +1,91 @@
/* 画布与节点的局部样式: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; }
@@ -127,43 +94,33 @@
.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; }
@@ -171,32 +128,24 @@
.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; }
@@ -207,24 +156,17 @@
.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; }
@@ -234,40 +176,32 @@
.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; }
-34
View File
@@ -1,121 +1,95 @@
/* 当前 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; }
@@ -123,13 +97,11 @@
.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; }
@@ -138,11 +110,8 @@
.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; }
@@ -155,7 +124,6 @@
.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; }
@@ -191,7 +159,6 @@
.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; }
@@ -203,5 +170,4 @@
.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; } }
-5
View File
@@ -1,5 +1,3 @@
/* IconButton 的共享尺寸、交互态和 tooltip 外观;尺寸从所在 Toolbar 的自定义属性继承,缺省为 32px 控件/20px 图标。 */
/* 固定 flex 尺寸避免拥挤时压缩;inline-flex 居中图标,透明背景显露宿主表面,文字颜色继承宿主。 */
.button { .button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -15,13 +13,10 @@
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; }
-4
View File
@@ -1,5 +1,3 @@
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
.surface { .surface {
--toolbar-icon-size: 20px; --toolbar-icon-size: 20px;
--toolbar-control-width: 32px; --toolbar-control-width: 32px;
@@ -20,7 +18,5 @@
} }
/* 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,15 +1,9 @@
/* 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,5 +1,3 @@
/* 工作区壳体、目录、账户菜单与画布浮动标题;CSS Module 负责显示,账号/画布生命周期和侧栏开关由组件管理。 */
/* 侧栏宽度变量供侧栏本体和浮动标题共同消费,根容器至少占满动态视口高度。 */
.shell { .shell {
--sidebar-width: 270px; --sidebar-width: 270px;
display: flex; display: flex;
@@ -7,7 +5,6 @@
background: #fffefb; background: #fffefb;
color: #37352f; color: #37352f;
} }
/* 侧栏插槽 sticky 固定在滚动视口顶部;裁剪展开过渡期间的内容,层级高于画布。 */
.sidebarSlot { .sidebarSlot {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -17,7 +14,6 @@
overflow: hidden; overflow: hidden;
z-index: 30; z-index: 30;
} }
/* 侧栏采用纵向 flex,导航区可滚动、账户区留在底部;最大宽度给主区域保留至少 40px。 */
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -33,38 +29,30 @@
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;
@@ -85,40 +73,31 @@
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; }
@@ -127,48 +106,35 @@
.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; }
+3 -8
View File
@@ -2,7 +2,6 @@
* 嵌套 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';
@@ -23,10 +22,9 @@ 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 : {};
/** type/knots 可识别时逐点换空间;其它路径原样返回,不在本函数完整验证曲线形状。 /** 对已识别的 bezier-chain 换空间;未知路径原样返回,严格写入边界由 convertPath 把关。
* 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 模式把变换平移归零,仅施加旋转/比例。 */
@@ -44,15 +42,13 @@ 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()){
@@ -69,8 +65,7 @@ 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/同变换先原样返回;只有实际需要换空间时才检查支持的曲线格式,不支持就拒绝。 /** 严格曲线换空间:null/同变换无需改;不支持的路径拒绝,不擅自解释未知 payload。 */
* 两次转换分别完成旧局部→世界、世界→新局部;无须改变 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');
+3 -5
View File
@@ -1,7 +1,6 @@
/** /**
* 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。 * 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。
* 普通路径段只访问自有字段,不用字符串相似度猜 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';
@@ -15,7 +14,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)) {
@@ -24,8 +23,7 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
} }
} }
} }
/** 在 slot 上读取引用 ID;整个 slot 为 null 表示无引用,many 要求数组,每个 ID 必须非空字符串。 /** 在 slot 上读取引用 ID;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;
+5 -11
View File
@@ -1,7 +1,6 @@
/** /**
* 同步事务内的大批创建优化。追加表用固定成员的 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';
@@ -15,14 +14,12 @@ 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;
// 当前 input 若含 64 个 added,本次先通过 readMap(input) 折叠,再以新 added 放入第 65 个;不是整个事务最多 64 项。 // 控制覆盖层大小;到阈值后重建 base,而不是让链条随着事务长度无限加深。
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,
@@ -32,17 +29,14 @@ 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;
} }
/** Materialize transaction-local append tables before publication. */ /** Only published documents use the core's owned table envelopes. */
/** 只有已标记 draft 通过空 changes 物化成工厂 envelope/readMap;其它文档原样返回。 /** 只有已标记的 draft 需要通过空 changes 重新捕获表;普通文档原样复用。 */
* 不在这里校验 schema/树,也不把任何外来 Document 自动视为已校验或深度不可变。 */
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document; export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document;
+8 -17
View File
@@ -22,14 +22,12 @@ 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');
@@ -42,7 +40,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'
@@ -54,11 +52,9 @@ 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 栈还原未来重做时间线。
@@ -67,12 +63,11 @@ 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 resize preserves each dimension explicitly changed by the user. // A future explicit resize remains a resize to the user's chosen dimensions.
// Its before value and unchanged dimensions may inherit the default correction. // Only its before value inherits the asynchronous 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 中按宽/高分别判断:被用户改过的维度将 ratio 置 1,保留其 after; /** redo 中用户显式改过的维度停止继承默认比例;仅修正其 before,不改用户指定的 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' };
@@ -81,10 +76,9 @@ export function planCreationSize(document: Document, history: HistoryState, regi
resizeConsumed = true; resizeConsumed = true;
}; };
const changes: Change[] = []; const changes: Change[] = [];
// 目标整记录/geometry 变化从 amended 重取 before;其它变化保留原条件,继续用 applyChanges 校验。 // 每条 next 都基于 amended 的实际 before 重建,保证修正后的连续历史仍条件匹配。
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];
@@ -104,7 +98,6 @@ 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 }));
} }
@@ -119,12 +112,10 @@ 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,把其生成变化应用后的目标记录与直接候选比较;不一致就拒绝。 // 最后用组件自己的 resize handler 对照约束,自动尺寸不能绕过组件 min/max/aspect 政策。
// 这能执行组件实际实现的限制,不凭 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 -3
View File
@@ -4,14 +4,12 @@
*/ */
// 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;
} }
+4 -8
View File
@@ -12,8 +12,7 @@ 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]);
@@ -24,7 +23,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);
// commands 仅给 editor 逐成员做 policy 检查,不在此执行 element.move handler;几何转换直接由上面的坐标数学生成。 // 即使最终用 changes 原子提交,也要向 editor 暴露逐成员 policy 检查,防止入口权限越权。
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});
@@ -36,8 +35,7 @@ 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.
// 扫描整个 promoted 文档中已知组件声明的 canvas 引用,因此子树外的引用也可能需要修正。 // 只解释已知组件明确声明的 canvas 引用;opaque payload 和普通字面 ID 文本保留。
// 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')??[];
@@ -50,14 +48,12 @@ 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 由返回 commands 交给 editor 重查。 // 仅产生实际改变的顶层 data 字段,并要求 content.edit 能力/宿主 policy 通过。
// 不调用 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});
} }
+7 -20
View File
@@ -16,8 +16,7 @@ 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,并脱离调用者的可变数据。
* 不在此处验证整棵树、组件 schema 或所有跨记录引用;完整发布前仍需 validateDocument。 * 不在此处验证整棵树或所有跨记录引用;完整发布前仍需 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');
@@ -62,19 +61,14 @@ 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&&registry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]); if(componentData&&registry.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);
} }
@@ -107,7 +101,6 @@ 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');
@@ -118,8 +111,7 @@ 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);
@@ -128,24 +120,21 @@ 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 上下文;当前执行器用它检查 field 目标的 owner。 /** 一批有序修改及其允许的 canvas 上下文;回放不能跨越已冻结的操作范围。 */
* 这不是覆盖所有 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 可跳过;field 变化在跳过前仍检查当前 owner 范围。 /** 顺序回放多批变化,已经满足的 after 可跳过,但仍检查目标的 scope。 */
* 批次未提供 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){
@@ -169,8 +158,7 @@ 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);
// 整元素修改在 conditional=true 时匹配 before;删除只记录已死亡的最大代次。 // 整元素修改必须匹配 before;删除同时保留墓碑,使旧 incarnation 永远不能复活。
// 此执行器不直接以墓碑拒绝后续插入,发布前 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) {
@@ -196,7 +184,6 @@ 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);
+6 -13
View File
@@ -2,7 +2,6 @@
* 可暂停/恢复的深复制准备状态机。检查点保存 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';
@@ -23,8 +22,7 @@ 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');
@@ -35,8 +33,7 @@ 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 开始。
@@ -59,8 +56,7 @@ 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 跳过源的组件 payload schema。 // 源校验每次重开重新执行;topologyOnly 仅用于明确的结构源,最终输出仍执行完整数据校验。
// 本解释器继续到 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;};
@@ -73,8 +69,7 @@ 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 且 emitted=0 时可接管范围/映射;不是完整计划已生成或已加入画布。 /** 源已校验且刚到 emit,范围/映射固定,可以交给运行时逐记录转换,不要求完整计划已生成。 */
* 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});},
@@ -103,8 +98,7 @@ 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;
} }
// 与同步规划器相同:显式选边、复制 canvas 的边及指向复制节点的入边;只有源端被复制不足以自动带上出边。 // 扫描上级入边与范围内边;随后 element/canvas 分配都把新 ID 写回 state,恢复可重用。
// 随后 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;
@@ -116,8 +110,7 @@ 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]});},
}); });
} }
+5 -15
View File
@@ -2,7 +2,6 @@
* 完整快照上的深复制纯规划:先确定范围和稳定 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';
@@ -21,7 +20,6 @@ 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)) {
@@ -39,15 +37,13 @@ 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 只删除声明的对象字段,数组下标不能直接删成空洞;是否清除任务状态取决于该组件 copy.omit, // 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 };
@@ -82,7 +78,6 @@ 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 互撞。
@@ -129,7 +124,6 @@ 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)! } };
@@ -146,11 +140,9 @@ 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);
@@ -163,8 +155,7 @@ 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 按序号 t=(index+1)/(count+1) 在线两端世界位移间插值,不按弧长或几何距离加权。 // knot 按链中序号在两端位移间插值;一端仍为原节点时,不把整条线简单平移。
// 这里只移动 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 }) }; }) };
} }
@@ -176,8 +167,7 @@ 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 的第三参数仍是这个只读接口。
+15 -39
View File
@@ -1,8 +1,7 @@
/** /**
* 纯编辑器与单 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';
@@ -30,13 +29,9 @@ 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;
@@ -63,20 +58,17 @@ 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 按 Document 对象身份保存查询,不按整个 Root 身份;同文档的 runtime-only 发布会更新这条关联。 // WeakMap 按确切快照身份保存查询,既拒绝错误快照,也不永久保留已废弃文档。
// 旧 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);
@@ -115,13 +107,11 @@ 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 与相关边。
@@ -152,7 +142,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]);
// 按输入字段拆成 move→resize→rotate;上方检查输出目标与 geometry 字段,具体几何语义仍由 handler 实现。 // 混合几何 patch 按 move→resize→rotate 分发,各 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'))],
@@ -163,7 +153,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
changes = output; changes = output;
} }
} }
// 创建先放入临时 draft 容器,整批结束统一物化;其它修改沿有序 changes 应用,返回的变化另做 JSON 捕获。 // 创建可先放入临时 draft 容器,整批结束统一物化,减少大量创建时的重复表复制。
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)) };
} }
@@ -180,8 +170,7 @@ 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. */ } }
} }
@@ -210,8 +199,7 @@ 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) {
// 按变化种类收集参与者:canvas 自身及父项、元素旧/新 owner、新建边或端点变化涉及的端点 owner。 // 收集旧/新 owner、父 canvas 和端点 owner,形成跨图版本/claim 的完整参与者范围。
// 这是出站意图的 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') {
@@ -251,7 +239,6 @@ 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');
// 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。 // 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。
@@ -262,15 +249,14 @@ 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,正常 Error 失败会阻止发布;回调结束后 tx 关闭。 /** 同步事务:多条 dispatch 共享 draft,任一命令失败使整批拒绝;回调结束后 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 记录规划抛错;常规 Error 被 failed 真值分支记住,后续命令停止推进。 */ /** 私有 dispatch 记住第一次失败,后续命令不再继续推进 draft。 */
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) };
@@ -291,7 +277,6 @@ 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')) {
@@ -307,11 +292,10 @@ 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);
} }
// 仅从当前 history 淘汰最旧 entry;未解决操作的 historyTransition 等证据仍可能 pin 住旧资源。 // 容量淘汰移除最旧 entry;资源 pin 的后续扫描以更新后的 history 为准。
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; }
@@ -320,8 +304,7 @@ 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 }; }
@@ -336,7 +319,6 @@ 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);
@@ -344,7 +326,6 @@ 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);
@@ -354,8 +335,7 @@ 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;
@@ -383,7 +363,6 @@ 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);
@@ -394,7 +373,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); }
@@ -405,8 +384,7 @@ 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;
// 替换时要求两栈精确覆盖 entries、历史关联的 operation ID 存在,已有操作内容完全相等。 // 恢复替换必须让两栈精确覆盖 entry,entry 的 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');
@@ -414,7 +392,6 @@ 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);
@@ -430,8 +407,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
dispose() { disposed = true; listeners.clear(); }, dispose() { disposed = true; listeners.clear(); },
}); });
} }
/** 比较表的迭代顺序与内容,先做身份/大小快判;不是忽略插入顺序的 Map 集合等价。 /** 比较规范化表,先做身份/大小快判;无净变化的事务不增加 revision 或历史。 */
* 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;
+2 -5
View File
@@ -8,8 +8,7 @@ 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);
@@ -22,8 +21,7 @@ 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。 */
@@ -31,7 +29,6 @@ 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;
+6 -11
View File
@@ -18,12 +18,11 @@ 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 层;不是单独的 JSON/schema 校验器。 /** 检查可安全跨持久切点复用的深不可变普通数据,最多 64 层。
* 冻结的 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;
@@ -45,8 +44,7 @@ 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??[])});}
@@ -54,10 +52,9 @@ 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 校验;未传时只检查 DTO/区间形状,不解释每条 Change 的领域语义。 * checkChanges 可由调用方注入更严格的 Change 校验,本函数本身不依赖 registry。 */
* 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');};
@@ -75,9 +72,8 @@ 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 检查元数据/数组区间并在私有副本上正向或反向回放。 /** 先检查栈和每个区间仍匹配预期,再在私有副本上正向/反向回放元数据。
* 不改写输入两栈/entries 表;不能拿旧 journal 覆盖新的历史顺序。 * 任一冲突抛出而不修改输入 history;不能拿旧 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');
@@ -95,7 +91,6 @@ 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'));
+2 -5
View File
@@ -9,17 +9,15 @@ 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;
}; };
@@ -33,6 +31,5 @@ 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};
} }
+2 -3
View File
@@ -7,9 +7,8 @@ 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,原始值直接返回。
* 输入先经过 JSON 序列化语义:toJSON/getter 可能执行,稀疏槽/数组中的非法 JSON 值可能转为 null。 * clone 会按 JSON 规则处理数据,所以不可用它代替迁移输入的严格捕获校验。 */
* 循环或 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);
+5 -11
View File
@@ -2,7 +2,6 @@
* 组件定义的装配与显式数据迁移。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';
@@ -12,8 +11,7 @@ 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');
// 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。 // 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。
@@ -49,8 +47,7 @@ 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));
} }
// 这里只按 componentId+dataVersion 分派,不执行 data schema 或结构校验;known 不是整条记录合法的证明。 // 只识别已安装的精确数据版本;可保留未知记录,但不能假设它可被当前 handler 编辑。
// 可保留未知记录,但不能假设它可被当前 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';
@@ -62,10 +59,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;getter/转换器自身抛出的异常不在这里包装。 // 统一带原元素 ID 抛出 invalid_migration,便于调用方保留原记录并报告失败对象。
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=>{
@@ -81,7 +78,6 @@ 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)])));
}; };
@@ -92,11 +88,9 @@ 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 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。
+2 -8
View File
@@ -1,7 +1,6 @@
/** /**
* 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 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';
@@ -19,13 +18,11 @@ 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);}
// 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。 // 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。
@@ -38,13 +35,11 @@ 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. */
/** 端点或端点 owner 改变后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。 /** 端点重挂后,把边移到两端所属 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。
@@ -52,6 +47,5 @@ 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);
} }
+1 -3
View File
@@ -30,15 +30,13 @@ 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;
+6 -14
View File
@@ -5,13 +5,11 @@
*/ */
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 || [] });
@@ -26,15 +24,13 @@ export function freeze<T>(value: T): T {
return value; return value;
} }
/** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。 /** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。
* 此处不是校验器:JSON 会执行 toJSON、读取可枚举字段并丢弃/改写不支持的值。 * 此处不是校验器:JSON 会丢弃某些非法字段,因此外部输入必须在复制前验证。 */
* 不保留原型、描述符或共享子对象身份;需要严格输入语义时须在调用边界捕获。 */
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;
} }
/** 先以 Object.is 判断,再按可枚举自有字符串键递归比较 JSON 风格的值;数组与对象不能互相视为相等。 /** 按可枚举自有键递归比较 JSON 风格的值;数组与对象不能互相视为相等。
* 不检查 Symbol/不可枚举字段,数组长度本身也不在 Object.keys 中;稀疏数组不适合作为此比较器的输入。 * 不处理任意循环对象,也不把 Map/Set 等实例当成业务值来比较。 */
* 不处理任意循环对象,也不提供 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;
@@ -50,7 +46,6 @@ 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),
@@ -63,7 +58,6 @@ 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(),
@@ -72,9 +66,7 @@ export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
return Object.freeze(view); return Object.freeze(view);
} }
/** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。 /** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。
* 对象要求 Object.prototype 原型,但只遍历 Object.values;Symbol/不可枚举属性不参与。 * 这里只检查值形状,不验证组件 schema,也不是所有原型/属性描述符的严格捕获器。 */
* 数组用 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;
File diff suppressed because it is too large Load Diff
-40
View File
@@ -1,17 +1,10 @@
/**
* 已构建 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 } } };
@@ -20,28 +13,23 @@ 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");
@@ -49,11 +37,9 @@ 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; }
@@ -64,7 +50,6 @@ 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();
@@ -74,7 +59,6 @@ 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;
@@ -116,10 +100,8 @@ 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,
[ [
@@ -136,12 +118,10 @@ 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)
@@ -155,7 +135,6 @@ 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);
@@ -167,7 +146,6 @@ 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);
@@ -176,7 +154,6 @@ 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;
@@ -186,7 +163,6 @@ 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 },
@@ -214,7 +190,6 @@ 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"]) {
@@ -227,7 +202,6 @@ 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 } });
@@ -237,7 +211,6 @@ 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");
@@ -249,7 +222,6 @@ 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);
@@ -268,7 +240,6 @@ 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/);
@@ -280,7 +251,6 @@ 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", {
@@ -293,7 +263,6 @@ 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 },
@@ -318,7 +287,6 @@ 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();
@@ -328,7 +296,6 @@ 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"');
@@ -337,7 +304,6 @@ 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";
@@ -351,12 +317,10 @@ 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(
( (
@@ -387,20 +351,17 @@ 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: "{}",
}); });
@@ -410,7 +371,6 @@ 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");
-8
View File
@@ -1,13 +1,7 @@
/**
* 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'));
@@ -16,11 +10,9 @@ 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'});
-10
View File
@@ -1,17 +1,10 @@
/**
* 旧画布附件上传助手和显示 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)=>{
@@ -20,18 +13,15 @@ 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');
-14
View File
@@ -1,23 +1,16 @@
/**
* 旧 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;
@@ -33,7 +26,6 @@ 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);
@@ -46,7 +38,6 @@ 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;
@@ -60,7 +51,6 @@ 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;
@@ -70,7 +60,6 @@ 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;
@@ -81,7 +70,6 @@ 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;
@@ -92,7 +80,6 @@ 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;
@@ -108,7 +95,6 @@ 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"; },
-15
View File
@@ -1,19 +1,12 @@
/**
* 旧 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 });
@@ -34,14 +27,12 @@ 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);
@@ -58,7 +49,6 @@ 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 });
@@ -71,7 +61,6 @@ 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);
@@ -82,7 +71,6 @@ 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: [] };
@@ -96,7 +84,6 @@ 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] };
@@ -107,7 +94,6 @@ 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 = {
@@ -129,7 +115,6 @@ 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();
-13
View File
@@ -1,7 +1,3 @@
/**
* 本地图标目录/分片和共享 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";
@@ -12,12 +8,10 @@ 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");
@@ -36,16 +30,13 @@ 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"; });
@@ -56,10 +47,8 @@ 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++;
@@ -73,7 +62,6 @@ 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);
@@ -85,7 +73,6 @@ 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) => {
-7
View File
@@ -1,7 +1,3 @@
/**
* 旧画布 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";
@@ -9,7 +5,6 @@ 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);
@@ -23,7 +18,6 @@ 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 });
@@ -33,7 +27,6 @@ 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);
-31
View File
@@ -1,32 +1,21 @@
/**
* 旧 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');
@@ -38,7 +27,6 @@ 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);
@@ -46,36 +34,29 @@ 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});
@@ -83,7 +64,6 @@ 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');
@@ -96,16 +76,13 @@ 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');
@@ -114,7 +91,6 @@ 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});
@@ -126,7 +102,6 @@ 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();});
@@ -137,17 +112,13 @@ 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});
@@ -156,7 +127,6 @@ 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});
@@ -166,6 +136,5 @@ 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();});
} }
-10
View File
@@ -1,15 +1,9 @@
/**
* 响应头、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"'}});
@@ -17,18 +11,15 @@ 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++;}}));
@@ -36,7 +27,6 @@ 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 {
-9
View File
@@ -1,16 +1,11 @@
/**
* 旧快照画布的分支创建、文字样式、图标变换与恢复校验。这里只验证 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();
@@ -27,7 +22,6 @@ 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 };
@@ -36,7 +30,6 @@ 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 };
@@ -52,7 +45,6 @@ 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;
@@ -63,7 +55,6 @@ 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);
-18
View File
@@ -1,20 +1,12 @@
/**
* 旧 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) => {
@@ -31,7 +23,6 @@ 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() }, () => {});
@@ -39,7 +30,6 @@ 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() }, () => {});
@@ -47,7 +37,6 @@ 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 }, () => {});
@@ -59,7 +48,6 @@ 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 = [];
@@ -69,13 +57,11 @@ 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");
@@ -86,7 +72,6 @@ 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" };
@@ -100,7 +85,6 @@ 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");
@@ -108,7 +92,6 @@ 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:[]};
@@ -121,7 +104,6 @@ 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=[];
-35
View File
@@ -1,8 +1,3 @@
/**
* 旧画布 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
@@ -12,46 +7,37 @@ 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) => {
@@ -61,33 +47,26 @@ 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(); });
@@ -102,14 +81,12 @@ 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(); });
@@ -128,16 +105,13 @@ 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");
@@ -157,7 +131,6 @@ 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 });
@@ -176,7 +149,6 @@ 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="分区名称显示模式"]');
@@ -198,7 +170,6 @@ 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");
@@ -215,7 +186,6 @@ 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(); });
@@ -239,25 +209,20 @@ 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(); }
-23
View File
@@ -1,31 +1,19 @@
/**
* 旧快照画布的 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);
} }
@@ -40,7 +28,6 @@ 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/"]');
@@ -49,15 +36,12 @@ 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');
@@ -69,23 +53,19 @@ 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;
@@ -93,13 +73,10 @@ 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();});}
} }
-25
View File
@@ -1,27 +1,18 @@
/**
* 旧 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);
@@ -31,7 +22,6 @@ 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;
@@ -42,7 +32,6 @@ 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++;
@@ -51,7 +40,6 @@ 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'){
@@ -60,7 +48,6 @@ 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'});}
@@ -68,7 +55,6 @@ 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);}
@@ -81,13 +67,11 @@ 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++;
@@ -97,7 +81,6 @@ 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));});
@@ -111,12 +94,10 @@ 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');
@@ -124,7 +105,6 @@ 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');
@@ -132,7 +112,6 @@ 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="展开子画布"]'));
@@ -148,20 +127,17 @@ 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');
@@ -175,7 +151,6 @@ 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');
-17
View File
@@ -1,7 +1,3 @@
/**
* 旧 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';
@@ -9,9 +5,7 @@ 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'}]};
@@ -20,40 +14,33 @@ 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;
@@ -64,12 +51,10 @@ 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);
@@ -78,12 +63,10 @@ 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};
-14
View File
@@ -1,12 +1,7 @@
/**
* 旧画布快照 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 })];
@@ -14,7 +9,6 @@ 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;
@@ -29,7 +23,6 @@ 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" }] });
@@ -42,7 +35,6 @@ 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;
@@ -53,7 +45,6 @@ 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==");
@@ -69,7 +60,6 @@ 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"; },
@@ -83,13 +73,11 @@ 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: [] } });
@@ -101,7 +89,6 @@ 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)) {
@@ -127,7 +114,6 @@ 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 });
-14
View File
@@ -1,25 +1,16 @@
/**
* 应用反馈组件的 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");
@@ -27,7 +18,6 @@ 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();
@@ -40,14 +30,12 @@ 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 });
@@ -55,14 +43,12 @@ 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");
} }
File diff suppressed because it is too large Load Diff
+12 -173
View File
@@ -1,14 +1,8 @@
/**
* 复制实际 dashboard/GraphPage/headless renderer/Next proxy 到临时应用,连接本地 HTTP fixture,再用隔离 Chrome 操作真实页面。
* 引擎、ReactFlow、IndexedDB 和代理走应用实现,文档/AI/上传/授权响应由下面的内存服务提供,不是远程 Foundry 验证。
* 执行会启动 Next 编译/开发服务,性能生产模式还会 build;本文件不是轻量 node:test,也不会在纯注释检查时执行。
* 计数与测试入口仅写入临时副本,所有嵌入字符串都是测试输入;finally 释放屏障、连接、进程和临时目录。
*/
import {execFileSync} from 'node:child_process'; import {execFileSync} from 'node:child_process';
import {createCdpCalls} from './cdp-client.mjs'; import {createCdpCalls} from './cdp-client.mjs';
import {measureRenderer} from './renderer-performance.mjs'; import {measureRenderer} from './renderer-performance.mjs';
import {createRendererMigrationFixture,verifyRendererMigration} from './renderer-migration.mjs'; import {createRendererMigrationFixture,verifyRendererMigration} from './renderer-migration.mjs';
/** Actual dashboard, GraphPage, headless renderer, browser storage and Next proxy. */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { mkdtemp, cp, copyFile, symlink, readFile, writeFile, rm, realpath } from 'node:fs/promises'; import { mkdtemp, cp, copyFile, symlink, readFile, writeFile, rm, realpath } from 'node:fs/promises';
@@ -17,137 +11,98 @@ import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { setTimeout as pause } from 'node:timers/promises'; import { setTimeout as pause } from 'node:timers/promises';
import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts'; import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts';
// 默认 webpack,可显式选择 turbopack;两者在 fixture 放置路径与启动参数上采用不同约束。
const bundler=process.argv.includes('--turbopack')?'turbopack':'webpack'; const bundler=process.argv.includes('--turbopack')?'turbopack':'webpack';
// 生产模式只允许与性能入口组合,防止把功能测试默认为生产构建。
const productionMode=process.argv.includes('--production');if(productionMode&&!process.argv.includes('--performance'))throw Error('--production requires --performance'); const productionMode=process.argv.includes('--production');if(productionMode&&!process.argv.includes('--performance'))throw Error('--production requires --performance');
// migration-only、hooks-only 和 performance 分支各走自己的验证入口;迁移分支禁止混合其他模式。
const migrationOnly=process.argv.includes('--migration-only'); const migrationOnly=process.argv.includes('--migration-only');
const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000); const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000);
if(migrationOnly&&(hooksOnly||performanceMode))throw Error('migration-only requires its own functional entry'); if(migrationOnly&&(hooksOnly||performanceMode))throw Error('migration-only requires its own functional entry');
// 性能规模必须是 1..10000 的安全整数;复制期间原生输入探针还要求至少 1000 节点。
if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count'); if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count');
if(process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'&&(!performanceMode||performanceCount<1000))throw Error('concurrent input requires --performance and at least 1000 nodes'); if(process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'&&(!performanceMode||performanceCount<1000))throw Error('concurrent input requires --performance and at least 1000 nodes');
// 诊断 baseline 只接受十六进制 commit 标识;稍后只替换三个指定模块,不表示回放完整历史版本。
const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline'); const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline');
const source = resolve(import.meta.dirname, '../..'); const source = resolve(import.meta.dirname, '../..');
// Turbopack 要求真实依赖路径位于 workspace root,临时应用放在依赖的物理目录旁;webpack 使用系统临时目录。 // Turbopack fences real dependency paths to its workspace root. Keep its isolated
// fixture under the physical dependency workspace, then remove this exact path
// in finally. Webpack fixtures retain their ordinary OS-temp location.
const fixtureRoot=bundler==='turbopack'?dirname(await realpath(join(source,'node_modules'))):tmpdir(); const fixtureRoot=bundler==='turbopack'?dirname(await realpath(join(source,'node_modules'))):tmpdir();
// 应用和浏览器 profile 分别随机建目录;进程及临时文件归本次检查持有。
const directory = await mkdtemp(join(fixtureRoot, bundler==='turbopack'?'.headless-renderer-':'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-')); const directory = await mkdtemp(join(fixtureRoot, bundler==='turbopack'?'.headless-renderer-':'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-'));
// 稳定 UUID 形式用于测试作用域/任务身份,与业务元素 a/b 等符号 ID 分开。
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`; const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
// 各 Map 是内存服务事实源;calls 记录上游实际收到的字节/头,供断言请求次数与协议传递。
const account = id(1), workspace = id(2), root = id(3), other = id(4), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), assetBytes=new Map(), aiRuns = new Map(); const account = id(1), workspace = id(2), root = id(3), other = id(4), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), assetBytes=new Map(), aiRuns = new Map();
// 建立合法第一代文本节点;功能模式从两个节点和带可编辑路径的连线开始。
const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } }); const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } });
documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }])); documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }]));
documents.set(other, createDocument(other, [{ id: other, parent: null }], [])); documents.set(other, createDocument(other, [{ id: other, parent: null }], []));
// 性能模式替换根模型:展开的分区含 count 个网格节点,根锚点与入口连线用于复制和离屏边验证。
if(performanceMode){ if(performanceMode){
const frame={...node('perf-frame',root,30),componentId:'frame',data:{title:'Performance partition',text:''},geometry:{x:30,y:50,width:1000,height:600,rotation:0}},children=Array.from({length:performanceCount},(_,index)=>({...node(`perf-${index}`,partition,(index%20)*280),geometry:{x:(index%20)*280,y:Math.floor(index/20)*220,width:260,height:200,rotation:0}})),incoming={id:'perf-incoming',incarnation:1,componentId:'connection',dataVersion:1,structure:'connection',ownerCanvasId:root,source:{elementId:'perf-source'},target:{elementId:'perf-frame'},data:{},path:null}; const frame={...node('perf-frame',root,30),componentId:'frame',data:{title:'Performance partition',text:''},geometry:{x:30,y:50,width:1000,height:600,rotation:0}},children=Array.from({length:performanceCount},(_,index)=>({...node(`perf-${index}`,partition,(index%20)*280),geometry:{x:(index%20)*280,y:Math.floor(index/20)*220,width:260,height:200,rotation:0}})),incoming={id:'perf-incoming',incarnation:1,componentId:'connection',dataVersion:1,structure:'connection',ownerCanvasId:root,source:{elementId:'perf-source'},target:{elementId:'perf-frame'},data:{},path:null};
versions.set(partition,1);documents.set(root,createDocument(root,[{id:root,parent:null},{id:partition,parent:{canvasId:root,entryElementId:frame.id}}],[node('perf-source',root,-300),frame,...children,incoming])); versions.set(partition,1);documents.set(root,createDocument(root,[{id:root,parent:null},{id:partition,parent:{canvasId:root,entryElementId:frame.id}}],[node('perf-source',root,-300),frame,...children,incoming]));
} }
// 应用目录和标题接口返回两个根 board,canvas 规范模型仍由 documents 保存。
const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' })); const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' }));
// 固定 PNG 只供显示 URL 返回;上传元数据和原始上传字节另外保存,不能从此图片推导所有资源都做过解码。
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64'); const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
// 某个后续场景在前三项后挂住流,验证完整子 owner 可先写,而其他 owner 继续加载。
let scopeStreamRoot=null,releaseScopeStream;const scopeStreamGate=new Promise(resolve=>releaseScopeStream=resolve); let scopeStreamRoot=null,releaseScopeStream;const scopeStreamGate=new Promise(resolve=>releaseScopeStream=resolve);
// 分别标记错误尾部与被导航放弃的来源;abandonedGate 可令旧响应晚到。
let brokenStreamRoot=null,abandonedRoot=null,releaseAbandoned;const abandonedGate=new Promise(resolve=>releaseAbandoned=resolve); let brokenStreamRoot=null,abandonedRoot=null,releaseAbandoned;const abandonedGate=new Promise(resolve=>releaseAbandoned=resolve);
// 功能模式首读只发第一个元素即等待,给“边加载边编辑”留出可控窗口。
let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode&&!migrationOnly; let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode&&!migrationOnly;
// 包含特定导入标题的文档写入可挂起,editReceipts 按请求 ID 保存规范写入结果。
let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map(); let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map();
// AI 显式应用先改变服务状态再挂起响应,用于检查本地操作与未确认回执交错。
let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;}); let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;});
// 上传收到字节后先记录资源,uploadGate 决定何时确认,使本地预览与后端确认可分开观察。
let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; }); let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
// 将指定参与 canvas 的完整成员编码为局部/完整规范模型;此替身 removedCanvasIds 固定为空,不涵盖任意删除协议。
const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] }); const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] });
// 后续恢复场景可拒绝下一次编辑,资源采用场景可拒绝或延迟元数据授权。
let rejectNextEdit=false,rejectedEdit,denyAssetMetadata=false,metadataWait,releaseMetadata; let rejectNextEdit=false,rejectedEdit,denyAssetMetadata=false,metadataWait,releaseMetadata;
// 仅迁移入口装配专用路由/旧数据集;其他模式不运行迁移 fixture。
const migrationFixture=migrationOnly?createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}):undefined; const migrationFixture=migrationOnly?createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}):undefined;
// 上游在真实 HTTP 上接请求,但内部仅操作 Map;异常转成 500,使页面协议错误可观察。
const upstream = createServer(async (request, response) => { const upstream = createServer(async (request, response) => {
try { try {
const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = []; const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = [];
for await (const chunk of request) chunks.push(chunk); for await (const chunk of request) chunks.push(chunk);
const bytes = Buffer.concat(chunks); calls.push({ method: request.method, path, bytes, headers: request.headers }); const bytes = Buffer.concat(chunks); calls.push({ method: request.method, path, bytes, headers: request.headers });
// 统一 JSON 响应助手;登录、个人资料、workspace 和目录路由只满足实际页面装配。
const reply = (status, body) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); }; const reply = (status, body) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); };
if (path.endsWith('/auth/logout')) return reply(204, null); if (path.endsWith('/auth/logout')) return reply(204, null);
if (path.endsWith('/profile')) return reply(200, { id: account, username: 'fixture', display_name: 'Creator', email: null }); if (path.endsWith('/profile')) return reply(200, { id: account, username: 'fixture', display_name: 'Creator', email: null });
if (path.endsWith('/workspaces')) return reply(200, [{ id: workspace, name: 'Fixture workspace', role: 'admin' }]); if (path.endsWith('/workspaces')) return reply(200, [{ id: workspace, name: 'Fixture workspace', role: 'admin' }]);
if (path.endsWith('/materials')) return reply(200, { items: boards, next_offset: null }); if (path.endsWith('/materials')) return reply(200, { items: boards, next_offset: null });
const board = boards.find(board => path.endsWith('/materials/' + board.id)); if (board) return reply(200, board); const board = boards.find(board => path.endsWith('/materials/' + board.id)); if (board) return reply(200, board);
// 迁移专用处理器先尝试接管,再进入公共模型路由,避免两个 fixture 同时修改一个请求。
if(await migrationFixture?.handle(request,path,reply,bytes))return; if(await migrationFixture?.handle(request,path,reply,bytes))return;
const read = path.match(/\/materials\/([^/]+)\/canvas\/model$/); const read = path.match(/\/materials\/([^/]+)\/canvas\/model$/);
// 只有协商的 Accept 才返回规范 NDJSON;未协商请求走下面的完整 JSON 路径。
if(read&&request.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'){ if(read&&request.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'){
const value=model(documents.get(read[1])),token='fixture-snapshot-'+calls.length;let sequence=0; const value=model(documents.get(read[1])),token='fixture-snapshot-'+calls.length;let sequence=0;
response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'}); response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'});
// 同一 snapshotToken 内序号递增,每帧为一行 JSON;header 先提供完整 canvas、owner 清单和成员计数。
const line=record=>response.write(JSON.stringify({streamVersion:1,snapshotToken:token,sequence:sequence++,...record})+'\n'); const line=record=>response.write(JSON.stringify({streamVersion:1,snapshotToken:token,sequence:sequence++,...record})+'\n');
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length,canvasElementCounts:Object.fromEntries(value.canvases.map(canvas=>[canvas.id,value.elements.filter(element=>element.ownerCanvasId===canvas.id).length])),elementOwners:Object.fromEntries(value.elements.map(element=>[element.id,element.ownerCanvasId]))}); line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length,canvasElementCounts:Object.fromEntries(value.canvases.map(canvas=>[canvas.id,value.elements.filter(element=>element.ownerCanvasId===canvas.id).length])),elementOwners:Object.fromEntries(value.elements.map(element=>[element.id,element.ownerCanvasId]))});
// 错误尾部先发送有效首元素,再结束为非法 JSON;应保留可见前缀并报告失败。
if(read[1]===brokenStreamRoot){line({kind:'elements',elements:value.elements.slice(0,1)});response.end('{broken tail}\n');return;} if(read[1]===brokenStreamRoot){line({kind:'elements',elements:value.elements.slice(0,1)});response.end('{broken tail}\n');return;}
let start=0; let start=0;
// 作用域流和被放弃流各有自己的首块大小与 gate;响应已销毁时停止追加尾部。
if(read[1]===scopeStreamRoot){line({kind:'elements',elements:value.elements.slice(0,3)});start=3;await scopeStreamGate;if(response.destroyed)return;} if(read[1]===scopeStreamRoot){line({kind:'elements',elements:value.elements.slice(0,3)});start=3;await scopeStreamGate;if(response.destroyed)return;}
if(read[1]===abandonedRoot){line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await abandonedGate;if(response.destroyed)return;} if(read[1]===abandonedRoot){line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await abandonedGate;if(response.destroyed)return;}
// 首个根读取只触发一次 gate;其余元素随后每帧最多 64 项,再发送 complete 并结束 HTTP body。
if(gatedFirstModel&&read[1]===root&&value.elements.length){gatedFirstModel=false;line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await firstModelGate;} if(gatedFirstModel&&read[1]===root&&value.elements.length){gatedFirstModel=false;line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await firstModelGate;}
for(let offset=start;offset<value.elements.length;offset+=64)line({kind:'elements',elements:value.elements.slice(offset,offset+64)}); for(let offset=start;offset<value.elements.length;offset+=64)line({kind:'elements',elements:value.elements.slice(offset,offset+64)});
line({kind:'complete',elementCount:value.elements.length});return response.end(); line({kind:'complete',elementCount:value.elements.length});return response.end();
} }
if (read) return reply(200, { model: model(documents.get(read[1])) }); if (read) return reply(200, { model: model(documents.get(read[1])) });
const write = path.match(/\/materials\/([^/]+)\/canvas\/model\/operations$/); const write = path.match(/\/materials\/([^/]+)\/canvas\/model\/operations$/);
// 规范增量写入路径:检查账户、幂等键与参与版本,在内存服务模型上按顺序应用,再保存回执。此处不实现完整生产幂等数据库。
if (write) { if (write) {
const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]); const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]);
// 特定导入写入到达后仅设置观测标记并等待 gate,阻塞发生在文档提交前。
if(batch.operations.some(operation=>operation.changes.some(change=>change.kind==='element'&&change.after?.data?.title==='Imported NDJSON node'))){importWriteSeen=true;await importGate;} if(batch.operations.some(operation=>operation.changes.some(change=>change.kind==='element'&&change.after?.data?.title==='Imported NDJSON node'))){importWriteSeen=true;await importGate;}
assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id); assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id);
// 拒绝指定下一编辑时同时模拟远程 b 文字改成 Remote reviewed;返回绑定原请求的 terminal 零写入证据。
if(rejectNextEdit){rejectNextEdit=false;rejectedEdit=batch;const before=previous.elements.get('b');documents.set(write[1],applyChanges(previous,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:before.data.text,after:'Remote reviewed',beforePresent:true,afterPresent:true}]));versions.set(root,versions.get(root)+1);return reply(409,{protocol_version:2,request_id:batch.request_id,status:'not-applied',terminal:true,error:'version_conflict'});} if(rejectNextEdit){rejectNextEdit=false;rejectedEdit=batch;const before=previous.elements.get('b');documents.set(write[1],applyChanges(previous,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:before.data.text,after:'Remote reviewed',beforePresent:true,afterPresent:true}]));versions.set(root,versions.get(root)+1);return reply(409,{protocol_version:2,request_id:batch.request_id,status:'not-applied',terminal:true,error:'version_conflict'});}
for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id)??0); for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id)??0);
let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes); let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes);
documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,(versions.get(id)??0)+1); documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,(versions.get(id)??0)+1);
// 一次有序批次只把参与版本各推进一次,回执仍列出全部逻辑 operation IDs,供客户端覆盖检查。
const receipt={ protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false,Object.keys(batch.expected_versions)) };editReceipts.set(batch.request_id,receipt);return reply(200,receipt); const receipt={ protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false,Object.keys(batch.expected_versions)) };editReceipts.set(batch.request_id,receipt);return reply(200,receipt);
} }
// GET 查询原文档请求回执;查不到只给 unknown,不提供未执行证明。
const editQuery=path.match(/\/canvas\/model\/operations\/([^/]+)$/);if(editQuery)return editReceipts.has(editQuery[1])?reply(200,editReceipts.get(editQuery[1])):reply(404,{request_id:editQuery[1],status:'unknown'}); const editQuery=path.match(/\/canvas\/model\/operations\/([^/]+)$/);if(editQuery)return editReceipts.has(editQuery[1])?reply(200,editReceipts.get(editQuery[1])):reply(404,{request_id:editQuery[1],status:'unknown'});
if(path.endsWith('/canvas/ai/models'))return reply(200,{models:[{id:'fixture-text',label:'Fixture text',available:true,input_modalities:['text'],output_modalities:['text']}]}); if(path.endsWith('/canvas/ai/models'))return reply(200,{models:[{id:'fixture-text',label:'Fixture text',available:true,input_modalities:['text'],output_modalities:['text']}]});
// AI 受理检查账户、幂等键、If-Match 和完整预期版本向量;先保存配置再冻结计划,不在受理时自动写生成文字。
if(path.endsWith('/canvas/ai/runs')&&request.method==='POST'){ if(path.endsWith('/canvas/ai/runs')&&request.method==='POST'){
const input=JSON.parse(bytes.toString()),owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');assert.deepEqual(input.expected_versions,Object.fromEntries([...documents.get(root).canvases.keys()].map(id=>[id,versions.get(id)]))); const input=JSON.parse(bytes.toString()),owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');assert.deepEqual(input.expected_versions,Object.fromEntries([...documents.get(root).canvases.keys()].map(id=>[id,versions.get(id)])));
const previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.ai',before:element.data.ai??null,beforePresent:element.data.ai!==undefined,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate},afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1); const previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.ai',before:element.data.ai??null,beforePresent:element.data.ai!==undefined,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate},afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
const runId=id(700+aiRuns.size*10),plan={steps:[{step_id:'step-b',board_id:owner,node_id:input.node_id,canonical_root:root,target_incarnation:input.target_incarnation,kind:'text',model_id:input.model_id,status:'queued',dependencies:[],static_inputs:[]}],edges:[],frozen_versions:Object.fromEntries([...next.canvases.keys()].map(id=>[id,versions.get(id)]))}; const runId=id(700+aiRuns.size*10),plan={steps:[{step_id:'step-b',board_id:owner,node_id:input.node_id,canonical_root:root,target_incarnation:input.target_incarnation,kind:'text',model_id:input.model_id,status:'queued',dependencies:[],static_inputs:[]}],edges:[],frozen_versions:Object.fromEntries([...next.canvases.keys()].map(id=>[id,versions.get(id)]))};
// 受理以 202 返回任务/计划/目标 incarnation 及当前 owner 局部模型;事件初始只有 queued。
const admitted={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:versions.get(owner),document_format_version:1,target_incarnation:input.target_incarnation,model:model(next,false,[owner])}; const admitted={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:versions.get(owner),document_format_version:1,target_incarnation:input.target_incarnation,model:model(next,false,[owner])};
aiRuns.set(runId,{status:'queued',plan,admitted,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.queued',step_id:'step-b',board_id:owner,node_id:input.node_id,status:'queued'}]});return reply(202,admitted); aiRuns.set(runId,{status:'queued',plan,admitted,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.queued',step_id:'step-b',board_id:owner,node_id:input.node_id,status:'queued'}]});return reply(202,admitted);
} }
const aiAction=path.match(/\/canvas\/ai\/runs\/([^/]+)\/(cancel|apply)$/); const aiAction=path.match(/\/canvas\/ai\/runs\/([^/]+)\/(cancel|apply)$/);
// 取消与应用回执均携带请求/操作/任务身份;取消只改控制状态,应用则检查当前 owner 与版本后修改文字。
if(aiAction){ if(aiAction){
const run=aiRuns.get(aiAction[1]),input=JSON.parse(bytes.toString()),base={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,action:'run.'+aiAction[2],status:'applied',run_id:aiAction[1]}; const run=aiRuns.get(aiAction[1]),input=JSON.parse(bytes.toString()),base={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,action:'run.'+aiAction[2],status:'applied',run_id:aiAction[1]};
assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['x-expected-account'],account); assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['x-expected-account'],account);
// 取消追加 completed(cancelled) 事件并立即回执,不经过 applyGate。
if(aiAction[2]==='cancel'){run.status='cancelled';run.events.push({seq:run.events.length+1,run_id:aiAction[1],type:'run.completed',status:'cancelled'});return reply(200,{...base,run_status:'cancelled'});} if(aiAction[2]==='cancel'){run.status='cancelled';run.events.push({seq:run.events.length+1,run_id:aiAction[1],type:'run.completed',status:'cancelled'});return reply(200,{...base,run_status:'cancelled'});}
const owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(input.target_canvas_id,owner);assert.equal(input.target_incarnation,1);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"'); const owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(input.target_canvas_id,owner);assert.equal(input.target_incarnation,1);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');
const output=run.events.find(event=>event.type==='step.output'&&event.output.output_id===input.output_id).output,previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.text',before:element.data.text,after:output.text,beforePresent:true,afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1); const output=run.events.find(event=>event.type==='step.output'&&event.output.output_id===input.output_id).output,previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.text',before:element.data.text,after:output.text,beforePresent:true,afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
// 应用输出先在服务模型中生效并追加 step.applied,再等待回执 gate,模拟成功已发生但客户端仍在途。
const applied={...base,step_id:'step-b',board_id:owner,origin_board_id:run.plan.steps[0].board_id,canonical_root:root,node_id:input.node_id,output_id:input.output_id,document_format_version:1,target_incarnation:1,canvas_version:versions.get(owner),model:model(next,false,[owner])};run.events.push({...applied,seq:run.events.length+1,type:'step.applied'});await applyGate;return reply(200,applied); const applied={...base,step_id:'step-b',board_id:owner,origin_board_id:run.plan.steps[0].board_id,canonical_root:root,node_id:input.node_id,output_id:input.output_id,document_format_version:1,target_incarnation:1,canvas_version:versions.get(owner),model:model(next,false,[owner])};run.events.push({...applied,seq:run.events.length+1,type:'step.applied'});await applyGate;return reply(200,applied);
} }
// 事件页按 after_seq 过滤,next_seq 为最后返回序号;此 fixture 一页返回全部剩余事件且无后续页。
const aiPoll=path.match(/\/canvas\/ai\/runs\/([^/]+)$/); const aiPoll=path.match(/\/canvas\/ai\/runs\/([^/]+)$/);
if(aiPoll){const run=aiRuns.get(aiPoll[1]),after=Number(url.searchParams.get('after_seq')||0),events=run.events.filter(event=>event.seq>after);return reply(200,{run_id:aiPoll[1],status:run.status,plan:run.plan,steps:[],events,next_seq:events.at(-1)?.seq??after,has_more:false,outputs:[]});} if(aiPoll){const run=aiRuns.get(aiPoll[1]),after=Number(url.searchParams.get('after_seq')||0),events=run.events.filter(event=>event.seq>after);return reply(200,{run_id:aiPoll[1],status:run.status,plan:run.plan,steps:[],events,next_seq:events.at(-1)?.seq??after,has_more:false,outputs:[]});}
// 用真实 Request.formData 解析代理送来的 multipart,按资源 ID 保存字节和元数据;等上传 gate 后才返回 201。
if (path.endsWith('/assets') && request.method === 'POST') { if (path.endsWith('/assets') && request.method === 'POST') {
const upload = new Request('http://fixture', { method: 'POST', body: bytes, headers: { 'Content-Type': request.headers['content-type'] } }), form = await upload.formData(), file = form.get('image'); const upload = new Request('http://fixture', { method: 'POST', body: bytes, headers: { 'Content-Type': request.headers['content-type'] } }), form = await upload.formData(), file = form.get('image');
const asset = { id: request.headers['x-resource-id'], bytes: file.size, mime_type: 'image/png', width: 4, height: 2, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${workspace}/assets/${request.headers['x-resource-id']}` }; const asset = { id: request.headers['x-resource-id'], bytes: file.size, mime_type: 'image/png', width: 4, height: 2, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${workspace}/assets/${request.headers['x-resource-id']}` };
@@ -155,31 +110,24 @@ const upstream = createServer(async (request, response) => {
return reply(201, { protocol_version: 2, request_id: request.headers['idempotency-key'], status: 'applied', asset }); return reply(201, { protocol_version: 2, request_id: request.headers['idempotency-key'], status: 'applied', asset });
} }
const assetMetadata=path.match(/\/assets\/metadata\/([^/]+)$/); const assetMetadata=path.match(/\/assets\/metadata\/([^/]+)$/);
// 元数据可延迟/拒绝;成功时对真实上传字节算 SHA-256,给授权采用与导入检查使用。
if(assetMetadata){if(metadataWait)await metadataWait;if(denyAssetMetadata||!assets.has(assetMetadata[1]))return reply(404,{error:'not_found'});const original=assetBytes.get(assetMetadata[1]),sha256=[...new Uint8Array(await crypto.subtle.digest('SHA-256',original))].map(n=>n.toString(16).padStart(2,'0')).join('');return reply(200,{metadata_version:1,asset:assets.get(assetMetadata[1]),sha256});} if(assetMetadata){if(metadataWait)await metadataWait;if(denyAssetMetadata||!assets.has(assetMetadata[1]))return reply(404,{error:'not_found'});const original=assetBytes.get(assetMetadata[1]),sha256=[...new Uint8Array(await crypto.subtle.digest('SHA-256',original))].map(n=>n.toString(16).padStart(2,'0')).join('');return reply(200,{metadata_version:1,asset:assets.get(assetMetadata[1]),sha256});}
// 普通资产显示路径返回固定 PNG,和上面的原始上传/摘要元数据检查属于不同证据。
if (/\/assets\/[^/]+$/.test(path)) { response.writeHead(200, { 'Content-Type': 'image/png' }); return response.end(png); } if (/\/assets\/[^/]+$/.test(path)) { response.writeHead(200, { 'Content-Type': 'image/png' }); return response.end(png); }
reply(404, { error: 'not_found' }); reply(404, { error: 'not_found' });
} catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: error.message })); } } catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: error.message })); }
}); });
// 先绑定本地上游,短暂监听器再为 Next 选端口;释放保留端口后才启动 Next,不是持续端口占用锁。
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)); await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
const reserve = createServer(); await new Promise(resolve => reserve.listen(0, '127.0.0.1', resolve)); const port = reserve.address().port; await new Promise(resolve => reserve.close(resolve)); const reserve = createServer(); await new Promise(resolve => reserve.listen(0, '127.0.0.1', resolve)); const port = reserve.address().port; await new Promise(resolve => reserve.close(resolve));
let next, chrome, socket, logs = ''; let next, chrome, socket, logs = '';
try { try {
// 所有源码插桩只写入临时副本,原仓库 src 不会被这些 replace 修改。
await cp(join(source, 'src'), join(directory, 'src'), { recursive: true }); await cp(join(source, 'src'), join(directory, 'src'), { recursive: true });
await cp(join(source, 'public'), join(directory, 'public'), { recursive: true }); await cp(join(source, 'public'), join(directory, 'public'), { recursive: true });
// baseline 模式只将 engine/scheduler/queries 替换为指定 commit 的文件,属于局部诊断对照。
if(performanceMode&&baseline)for(const file of ['src/lib/canvas-runtime/engine.ts','src/lib/canvas-runtime/scheduler.ts','src/lib/canvas-core/queries.ts'])await writeFile(join(directory,file),execFileSync('git',['show',`${baseline}:${file}`],{cwd:source})); if(performanceMode&&baseline)for(const file of ['src/lib/canvas-runtime/engine.ts','src/lib/canvas-runtime/scheduler.ts','src/lib/canvas-core/queries.ts'])await writeFile(join(directory,file),execFileSync('git',['show',`${baseline}:${file}`],{cwd:source}));
// 堆诊断仅在临时 storage.commit 注入回执保留计数,记录 Root/文档/回执元素规模,不能直接当作完整保留堆分析。
if(performanceMode&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'){ if(performanceMode&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'){
const file=join(directory,'src/lib/canvas-runtime/storage.ts'),text=await readFile(file,'utf8'),marker='async commit(leader: StorageLeader, batch: DurableBatch) {';assert.ok(text.includes(marker));await writeFile(file,text.replace(marker,marker+`const host=globalThis as typeof globalThis&{__fixtureReceiptRetention?:unknown};const payload=batch.image.payload as any;const receipts=payload.runtime?.receipts??[];const counts=receipts.map((row:any)=>Array.isArray(row[1]?.model?.elements)?row[1].model.elements.length:0);host.__fixtureReceiptRetention={rootRevision:batch.image.rootRevision,documentElements:payload.document?.elements?.length,receipts:receipts.length,modelElements:counts.reduce((a:number,b:number)=>a+b,0),maxModelElements:Math.max(0,...counts)};`)); const file=join(directory,'src/lib/canvas-runtime/storage.ts'),text=await readFile(file,'utf8'),marker='async commit(leader: StorageLeader, batch: DurableBatch) {';assert.ok(text.includes(marker));await writeFile(file,text.replace(marker,marker+`const host=globalThis as typeof globalThis&{__fixtureReceiptRetention?:unknown};const payload=batch.image.payload as any;const receipts=payload.runtime?.receipts??[];const counts=receipts.map((row:any)=>Array.isArray(row[1]?.model?.elements)?row[1].model.elements.length:0);host.__fixtureReceiptRetention={rootRevision:batch.image.rootRevision,documentElements:payload.document?.elements?.length,receipts:receipts.length,modelElements:counts.reduce((a:number,b:number)=>a+b,0),maxModelElements:Math.max(0,...counts)};`));
} }
// 包装批量测量更新,记录每批 ID 数;assert 源码锚点存在,避免插桩未命中却继续报告。 // Count delivered measurement batches in the isolated fixture only.
const internalsFile=join(directory,'src/lib/canvas-bindings/node-internals.ts'),internals=await readFile(internalsFile,'utf8');assert.ok(internals.includes('if(ids.size)update([...ids]);'));await writeFile(internalsFile,internals.replace('if(ids.size)update([...ids]);',`if(ids.size){const host=globalThis as typeof globalThis&{__fixtureInternalsBatches?:number[]};(host.__fixtureInternalsBatches??=[]).push(ids.size);update([...ids]);}`)); const internalsFile=join(directory,'src/lib/canvas-bindings/node-internals.ts'),internals=await readFile(internalsFile,'utf8');assert.ok(internals.includes('if(ids.size)update([...ids]);'));await writeFile(internalsFile,internals.replace('if(ids.size)update([...ids]);',`if(ids.size){const host=globalThis as typeof globalThis&{__fixtureInternalsBatches?:number[]};(host.__fixtureInternalsBatches??=[]).push(ids.size);update([...ids]);}`));
// 功能模式复制插件、hooks 与选择观察 fixture,并把组件接入临时页面;性能模式不装这些观察者。
if(!performanceMode){ if(!performanceMode){
await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx')); await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx'));
await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx')); await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx'));
@@ -187,25 +135,22 @@ try {
const canvasFile=join(directory,'src/components/canvas/headless-graph-canvas.tsx'),canvasSource=await readFile(canvasFile,'utf8'); const canvasFile=join(directory,'src/components/canvas/headless-graph-canvas.tsx'),canvasSource=await readFile(canvasFile,'utf8');
await writeFile(canvasFile,canvasSource.replace('"use client";','"use client";\nimport {NativeSelectionFixture} from "./selection-listener-fixture";').replace('<Background variant=','<NativeSelectionFixture/><Background variant=')); await writeFile(canvasFile,canvasSource.replace('"use client";','"use client";\nimport {NativeSelectionFixture} from "./selection-listener-fixture";').replace('<Background variant=','<NativeSelectionFixture/><Background variant='));
// 只统计完整场景 rebuild;已有 patch 路径不计数,逐元素 projection 另外统计,避免混用两个指标。 // Count actual scene rebuilds in the isolated fixture copy; production has no diagnostic global.
const sceneFile=join(directory,'src/lib/canvas-bindings/scene-projection.ts'),sceneSource=await readFile(sceneFile,'utf8');assert.ok(sceneSource.includes('const result=patched??projectCanvas('));await writeFile(sceneFile,sceneSource.replace('const result=patched??projectCanvas(',`const host=globalThis as typeof globalThis&{__fixtureSceneBuilds?:number};if(!patched)host.__fixtureSceneBuilds=(host.__fixtureSceneBuilds??0)+1;const result=patched??projectCanvas(`)); const sceneFile=join(directory,'src/lib/canvas-bindings/scene-projection.ts'),sceneSource=await readFile(sceneFile,'utf8');assert.ok(sceneSource.includes('const result=patched??projectCanvas('));await writeFile(sceneFile,sceneSource.replace('const result=patched??projectCanvas(',`const host=globalThis as typeof globalThis&{__fixtureSceneBuilds?:number};if(!patched)host.__fixtureSceneBuilds=(host.__fixtureSceneBuilds??0)+1;const result=patched??projectCanvas(`));
const projectionFile=join(directory,'src/lib/canvas-bindings/projection.ts'),projectionSource=await readFile(projectionFile,'utf8');assert.ok(projectionSource.includes(':ProjectionNode{'));await writeFile(projectionFile,projectionSource.replace(':ProjectionNode{',`:ProjectionNode{const host=globalThis as typeof globalThis&{__fixtureProjectedNodes?:Record<string,number>};host.__fixtureProjectedNodes??={};host.__fixtureProjectedNodes[element.id]=(host.__fixtureProjectedNodes[element.id]??0)+1;`)); const projectionFile=join(directory,'src/lib/canvas-bindings/projection.ts'),projectionSource=await readFile(projectionFile,'utf8');assert.ok(projectionSource.includes(':ProjectionNode{'));await writeFile(projectionFile,projectionSource.replace(':ProjectionNode{',`:ProjectionNode{const host=globalThis as typeof globalThis&{__fixtureProjectedNodes?:Record<string,number>};host.__fixtureProjectedNodes??={};host.__fixtureProjectedNodes[element.id]=(host.__fixtureProjectedNodes[element.id]??0)+1;`));
// 摘要 gate 延迟的是 Worker 已算出的全源结果,独立于后续 GatedDocument 的 parser I/O 屏障。 // Delay only the full-source Worker result in the isolated copy, independently of parser I/O.
const digestFile=join(directory,'src/lib/canvas-runtime/browser-digests.ts'),digestSource=await readFile(digestFile,'utf8');assert.ok(digestSource.includes('else finish(undefined,value.sha256);'));await writeFile(digestFile,digestSource.replace('else finish(undefined,value.sha256);',`else {const host=globalThis as typeof globalThis&{__fixtureDigestGate?:Promise<void>;__fixtureDigestWaiting?:boolean};if(host.__fixtureDigestGate){host.__fixtureDigestWaiting=true;void host.__fixtureDigestGate.then(()=>finish(undefined,value.sha256));}else finish(undefined,value.sha256);}`)); const digestFile=join(directory,'src/lib/canvas-runtime/browser-digests.ts'),digestSource=await readFile(digestFile,'utf8');assert.ok(digestSource.includes('else finish(undefined,value.sha256);'));await writeFile(digestFile,digestSource.replace('else finish(undefined,value.sha256);',`else {const host=globalThis as typeof globalThis&{__fixtureDigestGate?:Promise<void>;__fixtureDigestWaiting?:boolean};if(host.__fixtureDigestGate){host.__fixtureDigestWaiting=true;void host.__fixtureDigestGate.then(()=>finish(undefined,value.sha256));}else finish(undefined,value.sha256);}`));
// 通过临时 effect 暴露实际 engine 和公开 collectImportSources 动作;卸载时只清理同一注册函数。 // A fixture Host action exercises the public engine API; no debug entry point ships in the app.
const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8'); const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8');
await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType<ProjectEngine['collectImportSources']>;__fixtureActualEngine?:ProjectEngine};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;host.__fixtureActualEngine=engine;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]); await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType<ProjectEngine['collectImportSources']>;__fixtureActualEngine?:ProjectEngine};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;host.__fixtureActualEngine=engine;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]);
const subscribe =`)); const subscribe =`));
// 在 Host 扩展装配点安装自定义 renderer 与 HookChecks,锚点改变则测试准备失败,不静默少测插件。
const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";\nimport {HookChecks} from "@/components/canvas/hooks-fixture";').replace('extensions=[]','extensions=[fixtureExtension]').replace('{children}</Renderers.Provider>','{children}<HookChecks/></Renderers.Provider>')); const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";\nimport {HookChecks} from "@/components/canvas/hooks-fixture";').replace('extensions=[]','extensions=[fixtureExtension]').replace('{children}</Renderers.Provider>','{children}<HookChecks/></Renderers.Provider>'));
} }
// 临时应用沿用项目配置和 loader,再链接现有 node_modules,不另装依赖。
for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs', 'next.config.ts']) await copyFile(join(source, file), join(directory, file)); for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs', 'next.config.ts']) await copyFile(join(source, file), join(directory, file));
await cp(join(source,'scripts/loaders'),join(directory,'scripts/loaders'),{recursive:true}); await cp(join(source,'scripts/loaders'),join(directory,'scripts/loaders'),{recursive:true});
if(!performanceMode){ if(!performanceMode){
const loaderFile=join(directory,'scripts/loaders/xyflow-retained.cjs'); const loaderFile=join(directory,'scripts/loaders/xyflow-retained.cjs');
// 附加 loader 包装统计 ReactFlow 原生选择 selector 扫描次数;字符串写入临时 loader,不影响产品构建。
const instrument=` const instrument=`
const nativeTransform=module.exports; const nativeTransform=module.exports;
module.exports=function(source){return nativeTransform.call(this,source).replace('const selector$l = (s) => {','const selector$l = (s) => { globalThis.__fixtureSelectionScans=(globalThis.__fixtureSelectionScans??0)+1;');}; module.exports=function(source){return nativeTransform.call(this,source).replace('const selector$l = (s) => {','const selector$l = (s) => { globalThis.__fixtureSelectionScans=(globalThis.__fixtureSelectionScans??0)+1;');};
@@ -214,64 +159,50 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
} }
await symlink(join(source, 'node_modules'), join(directory, 'node_modules')); await symlink(join(source, 'node_modules'), join(directory, 'node_modules'));
// 统一 Next 子进程启动和日志尾部收集;独立进程组供 finally 一并终止,API origin 指向本次上游。
const launchNext=args=>{ const launchNext=args=>{
const child=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),...args],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'endoleap'},stdio:['ignore','pipe','pipe']}); const child=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),...args],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'endoleap'},stdio:['ignore','pipe','pipe']});
child.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});child.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});return child; child.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});child.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});return child;
}; };
let buildMs; let buildMs;
// 生产性能入口先完成 Next build 并记录耗时,非零退出立即失败;其他入口用 dev,仍可能触发页面编译。
if(productionMode){ if(productionMode){
console.log('BUILD production fixture');const started=Date.now();next=launchNext(['build',`--${bundler}`]); console.log('BUILD production fixture');const started=Date.now();next=launchNext(['build',`--${bundler}`]);
await new Promise((resolve,reject)=>{next.once('error',reject);next.once('exit',(code,signal)=>code===0?resolve():reject(Error(`production fixture build failed (${code??signal})\n${logs}`)));}); await new Promise((resolve,reject)=>{next.once('error',reject);next.once('exit',(code,signal)=>code===0?resolve():reject(Error(`production fixture build failed (${code??signal})\n${logs}`)));});
buildMs=Date.now()-started;console.log(`BUILD complete ${buildMs} ms`); buildMs=Date.now()-started;console.log(`BUILD complete ${buildMs} ms`);
} }
next=launchNext([productionMode?'start':'dev',...productionMode?[]:[`--${bundler}`],'--hostname','127.0.0.1','--port',String(port)]); next=launchNext([productionMode?'start':'dev',...productionMode?[]:[`--${bundler}`],'--hostname','127.0.0.1','--port',String(port)]);
// 有界探测真实代理 profile,并检查 Next 提前退出;后续页面等待还会验证应用是否实际可用。
for (let i = 0; i < 120; i++) { if (next.exitCode !== null) throw new Error(logs); try { const r = await fetch(`http://127.0.0.1:${port}/api/foundry/profile`, { signal: AbortSignal.timeout(1000) }); if (r.ok) break; } catch {} await pause(250); } for (let i = 0; i < 120; i++) { if (next.exitCode !== null) throw new Error(logs); try { const r = await fetch(`http://127.0.0.1:${port}/api/foundry/profile`, { signal: AbortSignal.timeout(1000) }); if (r.ok) break; } catch {} await pause(250); }
// 只保留本次隔离 Chrome 的日志尾部,CHROME_BIN 可覆盖可执行路径,不连接日常 profile。
let browserLogs=''; let browserLogs='';
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}`, 'about:blank'], { stdio: ['ignore','ignore','pipe'] }); 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}`, 'about:blank'], { stdio: ['ignore','ignore','pipe'] });
chrome.stderr.on('data',chunk=>{browserLogs=(browserLogs+chunk).slice(-16000);}); chrome.stderr.on('data',chunk=>{browserLogs=(browserLogs+chunk).slice(-16000);});
// 从本次 profile 的 DevToolsActivePort 发现调试端口,失败则不继续浏览器命令。
let debugPort; let debugPort;
for (let i = 0; i < 100; i++) { try { debugPort = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); } } for (let i = 0; i < 100; i++) { try { debugPort = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); } }
assert.ok(debugPort, 'Chrome did not start'); assert.ok(debugPort, 'Chrome did not start');
const tabs = await fetch(`http://127.0.0.1:${debugPort}/json/list`).then(response => response.json()); const tabs = await fetch(`http://127.0.0.1:${debugPort}/json/list`).then(response => response.json());
socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
const exceptions=[]; const exceptions=[];
// 使用有截止时间的共享 CDP 调用器;收集页面异常与 fatal 事件,便于失败时定位。
const call=createCdpCalls(socket,{onEvent(message){if(['Inspector.targetCrashed','Target.targetCrashed','Inspector.detached','Page.javascriptDialogOpening'].includes(message.method))browserLogs=(browserLogs+'\nCDP '+JSON.stringify(message)).slice(-16000);if(message.method==='Runtime.consoleAPICalled'&&message.params.type==='error')console.log('BROWSER ERROR',message.params.args.map(arg=>arg.description??arg.value).join(' '));if(message.method==='Runtime.exceptionThrown')exceptions.push(message.params.exceptionDetails.exception?.description??message.params.exceptionDetails.text);}}); const call=createCdpCalls(socket,{onEvent(message){if(['Inspector.targetCrashed','Target.targetCrashed','Inspector.detached','Page.javascriptDialogOpening'].includes(message.method))browserLogs=(browserLogs+'\nCDP '+JSON.stringify(message)).slice(-16000);if(message.method==='Runtime.consoleAPICalled'&&message.params.type==='error')console.log('BROWSER ERROR',message.params.args.map(arg=>arg.description??arg.value).join(' '));if(message.method==='Runtime.exceptionThrown')exceptions.push(message.params.exceptionDetails.exception?.description??message.params.exceptionDetails.text);}});
// 页面表达式等待 Promise 并按值返回;exceptionDetails 直接转换为测试失败。
const evaluate = async expression => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); return r.result?.value; }; const evaluate = async expression => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); return r.result?.value; };
// 最多 200 轮条件检查,先检查页面异常,每轮约隔 100ms;失败附作业摘要、页面文本和 Next 日志,整体等待仍包含各 CDP 调用耗时。
async function until(expression, label) { for (let i = 0; i < 200; i++) { if (exceptions.length) throw new Error(exceptions.join('\n')); if (await evaluate(expression)) return; await pause(100); } throw new Error(label + '\n' + JSON.stringify(await evaluate(`[...window.__fixtureActualEngine?.imports.getJobs().values()??[]].map(job=>({id:job.id,status:job.status,error:job.error,sourceError:job.source?.integrityError}))`)) + '\n' + await evaluate('document.body.innerText') + '\n' + logs); } async function until(expression, label) { for (let i = 0; i < 200; i++) { if (exceptions.length) throw new Error(exceptions.join('\n')); if (await evaluate(expression)) return; await pause(100); } throw new Error(label + '\n' + JSON.stringify(await evaluate(`[...window.__fixtureActualEngine?.imports.getJobs().values()??[]].map(job=>({id:job.id,status:job.status,error:job.error,sourceError:job.source?.integrityError}))`)) + '\n' + await evaluate('document.body.innerText') + '\n' + logs); }
// 启用页面/异常事件并固定 1280×900 视口,减少交互坐标和虚拟化断言随屏幕环境漂移。
await call('Runtime.enable');await call('Page.enable');await call('Target.setDiscoverTargets',{discover:true}); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }); await call('Runtime.enable');await call('Page.enable');await call('Target.setDiscoverTargets',{discover:true}); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${migrationFixture?.states[0].root??root}`; const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${migrationFixture?.states[0].root??root}`;
await call('Page.navigate', { url: route }); await call('Page.navigate', { url: route });
// 按模式选择性能、迁移、hooks 或完整交互;每条专用入口成功后都回到公共 finally 清理。
if(performanceMode){ if(performanceMode){
await measureRenderer({call,evaluate,documents,calls,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')}); await measureRenderer({call,evaluate,documents,calls,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
}else if(migrationOnly){await verifyRendererMigration({fixture:migrationFixture,call,evaluate,until,port,workspace,account,pause}); }else if(migrationOnly){await verifyRendererMigration({fixture:migrationFixture,call,evaluate,until,port,workspace,account,pause});
}else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report); }else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report);
}else{ }else{
// 完整交互先要求首个元素在尾部 gate 前可编辑,b 尚不存在、创建工具禁用且没有文档 POST。
await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail'); await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');
assert.equal(await evaluate('!!document.querySelector(".react-flow__node[data-id=b]")'),false); assert.equal(await evaluate('!!document.querySelector(".react-flow__node[data-id=b]")'),false);
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0); assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
assert.equal(await evaluate('document.querySelector("[aria-label=添加文本卡片]").disabled'),true); assert.equal(await evaluate('document.querySelector("[aria-label=添加文本卡片]").disabled'),true);
await until('document.querySelector(".react-flow__node[data-id=a]").getBoundingClientRect().x>300','initial partial node remained under the sidebar'); await until('document.querySelector(".react-flow__node[data-id=a]").getBoundingClientRect().x>300','initial partial node remained under the sidebar');
// 使用 CDP 原生双击进入已载入文本,输入 partial-local;来源还不完整时不能发送文档请求。
const partialTextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=a] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`); const partialTextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=a] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...partialTextPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...partialTextPoint,button:'left',buttons:0,clickCount:2}); await call('Input.dispatchMouseEvent',{type:'mousePressed',...partialTextPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...partialTextPoint,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=a] textarea')&&!document.activeElement.readOnly`,'loaded prefix text did not enter edit mode'); await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=a] textarea')&&!document.activeElement.readOnly`,'loaded prefix text did not enter edit mode');
await call('Input.insertText',{text:' partial-local'});const partialText=await evaluate('document.activeElement.value');assert.ok(partialText.endsWith(' partial-local'));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0); await call('Input.insertText',{text:' partial-local'});const partialText=await evaluate('document.activeElement.value');assert.ok(partialText.endsWith(' partial-local'));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
await evaluate(`window.partialTextarea=document.activeElement;void 0`); await evaluate(`window.partialTextarea=document.activeElement;void 0`);
// 捕获同一个 textarea 和 viewport 后放行来源,完成不得替换焦点 DOM 或重置视口。
const previewTransform=await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform'); const previewTransform=await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform');
await writeFile('/private/tmp/headless-loading-editable.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64')); await writeFile('/private/tmp/headless-loading-editable.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
// 只有这里才放行首读尾部;随后核对服务文字和 undo,证明前缀编辑的历史继续可用。
releaseFirstModel(); releaseFirstModel();
await until('!!document.querySelector("[data-headless-canvas]:not([data-model-loading])") && document.querySelectorAll(".react-flow__node").length === 2','full model did not finish the partial editor'); await until('!!document.querySelector("[data-headless-canvas]:not([data-model-loading])") && document.querySelectorAll(".react-flow__node").length === 2','full model did not finish the partial editor');
assert.equal(await evaluate('window.partialTextarea.isConnected&&document.activeElement===window.partialTextarea'),true,'tail completion remounted the active text editor'); assert.equal(await evaluate('window.partialTextarea.isConnected&&document.activeElement===window.partialTextarea'),true,'tail completion remounted the active text editor');
@@ -280,13 +211,11 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27}); await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});
await evaluate(`document.querySelector('[aria-label="撤销"]').click()`);for(let i=0;i<100&&documents.get(root).elements.get('a').data.text!=='Editable content';i++)await pause(20);assert.equal(documents.get(root).elements.get('a').data.text,'Editable content'); await evaluate(`document.querySelector('[aria-label="撤销"]').click()`);for(let i=0;i<100&&documents.get(root).elements.get('a').data.text!=='Editable content';i++)await pause(20);assert.equal(documents.get(root).elements.get('a').data.text,'Editable content');
console.log('PASS actual GraphPage edits the first prefix before the gated tail, keeps its active textarea/history/viewport and sends only after durable completion'); console.log('PASS actual GraphPage edits the first prefix before the gated tail, keeps its active textarea/history/viewport and sends only after durable completion');
// 启用原生选择监听后改选 b,停用时事件数和 selector 扫描数都应冻结,再启用后继续收到事件。
await until('!!window.__fixtureNativeSelection','native selection fixture did not mount'); await until('!!window.__fixtureNativeSelection','native selection fixture did not mount');
await evaluate('window.__priorNativeSelection=window.__fixtureNativeSelection.selected();window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await evaluate('window.__priorNativeSelection=window.__fixtureNativeSelection.selected();window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await evaluate('window.__fixtureNativeSelection.select(["b"]);true');await until('window.__fixtureNativeSelectionEvents.at(-1)?.nodes.join() === "b"','native selection hook missed its selected node'); await evaluate('window.__fixtureNativeSelection.select(["b"]);true');await until('window.__fixtureNativeSelectionEvents.at(-1)?.nodes.join() === "b"','native selection hook missed its selected node');
await evaluate('window.__fixtureNativeSelection.enable(false);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await evaluate('window.__fixtureNativeSelection.enable(false);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
const unsubscribedSelectionEvents=await evaluate('window.__fixtureNativeSelectionEvents.length'); const unsubscribedSelectionEvents=await evaluate('window.__fixtureNativeSelectionEvents.length');
// 实际扫描计数必须先大于零,避免“从未装上监听器”也通过卸载后零变化的检查。
const subscribedSelectionScans=await evaluate('window.__fixtureSelectionScans??0');assert.ok(subscribedSelectionScans>0,'native scan instrumentation did not observe the active hook'); const subscribedSelectionScans=await evaluate('window.__fixtureSelectionScans??0');assert.ok(subscribedSelectionScans>0,'native scan instrumentation did not observe the active hook');
await evaluate('window.__fixtureNativeSelection.select(["a"]);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureNativeSelectionEvents.length'),unsubscribedSelectionEvents,'native selection hook fired after unsubscription');assert.equal(await evaluate('window.__fixtureSelectionScans??0'),subscribedSelectionScans,'unsubscribed native selection still scanned the graph'); await evaluate('window.__fixtureNativeSelection.select(["a"]);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureNativeSelectionEvents.length'),unsubscribedSelectionEvents,'native selection hook fired after unsubscription');assert.equal(await evaluate('window.__fixtureSelectionScans??0'),subscribedSelectionScans,'unsubscribed native selection still scanned the graph');
await evaluate('window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await evaluate('window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
@@ -298,13 +227,10 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot'); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot');
assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true); assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true);
// 按钮辅助函数走实际页面 click;下一个键盘脚本只检查事件是否被 canvas 声明消费。
const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`); const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`);
// Ctrl/Meta、IME 组合态和未知键不被抢占,普通 h/v 工具快捷键应 preventDefault。
assert.deepEqual(await evaluate(`(()=>{const canvas=document.querySelector('[data-headless-canvas]');const fire=(key,options={})=>{const e=new KeyboardEvent('keydown',{key,bubbles:true,cancelable:true,...options});canvas.dispatchEvent(e);return e.defaultPrevented;};return [fire('h',{ctrlKey:true}),fire('v',{metaKey:true}),fire('h',{isComposing:true}),fire('F9'),fire('h'),fire('v')];})()`),[false,false,false,false,true,true]); assert.deepEqual(await evaluate(`(()=>{const canvas=document.querySelector('[data-headless-canvas]');const fire=(key,options={})=>{const e=new KeyboardEvent('keydown',{key,bubbles:true,cancelable:true,...options});canvas.dispatchEvent(e);return e.defaultPrevented;};return [fire('h',{ctrlKey:true}),fire('v',{metaKey:true}),fire('h',{isComposing:true}),fire('F9'),fire('h'),fire('v')];})()`),[false,false,false,false,true,true]);
console.log('PASS canvas keyboard routing leaves modifiers, IME and unknown keys unclaimed and consumes plain tool shortcuts'); console.log('PASS canvas keyboard routing leaves modifiers, IME and unknown keys unclaimed and consumes plain tool shortcuts');
// 创建、undo、redo 同时核对 DOM 节点数与 fixture 文档元素数,后者还含原连线,不能直接把两种计数混用。
await click('添加文本卡片'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'create did not render'); await click('添加文本卡片'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'create did not render');
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20); for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
assert.equal(documents.get(root).elements.size, 4); assert.equal(documents.get(root).elements.size, 4);
@@ -313,18 +239,15 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(documents.get(root).elements.size, 3); assert.equal(documents.get(root).elements.size, 3);
await click('重做'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'redo did not render'); await click('重做'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'redo did not render');
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20); for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
// 记录拖动前文档请求数,并在页面收集原生 pointer/mouse/capture 事件供失败诊断。
const beforeDrag = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length; const beforeDrag = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
await evaluate(`window.inputEvents=[];for(const type of ['pointerdown','mousedown','pointerup','mouseup','pointercancel','lostpointercapture'])window.addEventListener(type,event=>window.inputEvents.push({type,x:event.clientX,y:event.clientY,target:event.target.outerHTML?.slice(0,120)}),true);`); await evaluate(`window.inputEvents=[];for(const type of ['pointerdown','mousedown','pointerup','mouseup','pointercancel','lostpointercapture'])window.addEventListener(type,event=>window.inputEvents.push({type,x:event.clientX,y:event.clientY,target:event.target.outerHTML?.slice(0,120)}),true);`);
const rect = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()'); const rect = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()');
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'left', buttons: 1, clickCount: 1 });
// 十帧 pointer move 只更新预览,释放时只允许一个文档请求,最终位置以 pointerup 坐标为准。
for (let step = 1; step <= 10; step++) await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + step * 5, y: rect.y + step * 3, button: 'left', buttons: 1 }); for (let step = 1; step <= 10; step++) await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + step * 5, y: rect.y + step * 3, button: 'left', buttons: 1 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + 50, y: rect.y + 30, button: 'left', buttons: 0 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + 50, y: rect.y + 30, button: 'left', buttons: 0 });
for (let i = 0; i < 100 && documents.get(root).elements.get('b').geometry.x === 450; i++) await pause(20); for (let i = 0; i < 100 && documents.get(root).elements.get('b').geometry.x === 450; i++) await pause(20);
assert.ok(Math.abs(documents.get(root).elements.get('b').geometry.x - 500) < 1, JSON.stringify({ geometry: documents.get(root).elements.get('b').geometry, rect, writes: calls.filter(call => call.path.endsWith('/model/operations')).map(call => JSON.parse(call.bytes.toString()).operations), inputs: await evaluate('window.inputEvents'), text: await evaluate('document.body.innerText') })); assert.ok(Math.abs(documents.get(root).elements.get('b').geometry.x - 500) < 1, JSON.stringify({ geometry: documents.get(root).elements.get('b').geometry, rect, writes: calls.filter(call => call.path.endsWith('/model/operations')).map(call => JSON.parse(call.bytes.toString()).operations), inputs: await evaluate('window.inputEvents'), text: await evaluate('document.body.innerText') }));
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeDrag + 1, 'pointer frames became document requests'); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeDrag + 1, 'pointer frames became document requests');
// 再次移动后用 Escape 取消,再发送迟到 mouseReleased;服务几何和请求数都不能变化。
const beforeCancel = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length; const beforeCancel = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
const moved = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()'); const moved = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()');
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: moved.x, y: moved.y, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: moved.x, y: moved.y, button: 'left', buttons: 1, clickCount: 1 });
@@ -333,7 +256,6 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 0 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 0 });
await pause(100); assert.equal(documents.get(root).elements.get('b').geometry.x, 500); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel); await pause(100); assert.equal(documents.get(root).elements.get('b').geometry.x, 500); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel);
// 拖动中切 H/pan 也必须取消所属预览;后来的 pointerup 不能提交,V 恢复选择工具及已提交位置。
const toolPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()`); const toolPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...toolPoint,button:'left',buttons:1,clickCount:1}); await call('Input.dispatchMouseEvent',{type:'mousePressed',...toolPoint,button:'left',buttons:1,clickCount:1});
await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:toolPoint.x+35,y:toolPoint.y+25,button:'left',buttons:1}); await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:toolPoint.x+35,y:toolPoint.y+25,button:'left',buttons:1});
@@ -348,7 +270,6 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await until(`document.querySelector('[aria-label="选择工具(V)"]')?.getAttribute('aria-pressed')==='true'`,'selection tool did not render'); await until(`document.querySelector('[aria-label="选择工具(V)"]')?.getAttribute('aria-pressed')==='true'`,'selection tool did not render');
await until(`(()=>{const n=document.querySelector('.react-flow__node[data-id=b]'),m=new DOMMatrix(n.style.transform);return Math.abs(m.m41-500)<.01&&!n.classList.contains('dragging');})()`,'canceled native node did not return to its committed position'); await until(`(()=>{const n=document.querySelector('.react-flow__node[data-id=b]'),m=new DOMMatrix(n.style.transform);return Math.abs(m.m41-500)<.01&&!n.classList.contains('dragging');})()`,'canceled native node did not return to its committed position');
console.log('PASS actual drag-to-pan tool switch cancels preview and late pointerup produces no document request'); console.log('PASS actual drag-to-pan tool switch cancels preview and late pointerup produces no document request');
// 用实际 resize handle 开始缩放,期间合成 foreign pointerId=999 事件,不能提交或取消当前手势。
const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`); const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 });
await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 });
@@ -358,12 +279,10 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
for (let i=0;i<100&&documents.get(root).elements.get('b').geometry.width!==300;i++)await pause(20); for (let i=0;i<100&&documents.get(root).elements.get('b').geometry.width!==300;i++)await pause(20);
assert.equal(documents.get(root).elements.get('b').geometry.width,300,JSON.stringify({geometry:documents.get(root).elements.get('b').geometry,inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText')}));assert.equal(documents.get(root).elements.get('b').geometry.height,220); assert.equal(documents.get(root).elements.get('b').geometry.width,300,JSON.stringify({geometry:documents.get(root).elements.get('b').geometry,inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText')}));assert.equal(documents.get(root).elements.get('b').geometry.height,220);
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel+1); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel+1);
// resize 模式 Escape 分三种:原生 select 内、子控件已 preventDefault、工具栏自身;只有未被控件接管的工具栏 Escape 清理模式。
await click('八向拉伸模式');await until(`document.querySelector('[data-resize-toggle="b"]')?.getAttribute('aria-pressed')==='true'`,'resize mode did not open'); await click('八向拉伸模式');await until(`document.querySelector('[data-resize-toggle="b"]')?.getAttribute('aria-pressed')==='true'`,'resize mode did not open');
await evaluate(`document.querySelector('[data-node-style="b"] select').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','native selector Escape dismissed the canvas mode'); await evaluate(`document.querySelector('[data-node-style="b"] select').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','native selector Escape dismissed the canvas mode');
await evaluate(`(()=>{const button=document.querySelector('[data-resize-toggle="b"]');button.addEventListener('keydown',event=>event.preventDefault(),{once:true});button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));})()`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','toolbar ignored a child control that had claimed Escape'); await evaluate(`(()=>{const button=document.querySelector('[data-resize-toggle="b"]');button.addEventListener('keydown',event=>event.preventDefault(),{once:true});button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));})()`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','toolbar ignored a child control that had claimed Escape');
await evaluate(`document.querySelector('[data-resize-toggle="b"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');return !!node&&[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].every(view=>view.modes.get('b')!=='resize')&&!node.querySelector('[data-resize-handle]')&&!document.querySelector('[data-resize-toggle="b"][aria-pressed="true"]');})()`,'toolbar did not dismiss its own resize mode').catch(async error=>{console.log('MODE DIAGNOSTIC',await evaluate(`JSON.stringify({views:[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].map(view=>({id:view.id,modes:[...view.modes]})),buttons:[...document.querySelectorAll('[data-resize-toggle="b"]')].map(node=>({pressed:node.getAttribute('aria-pressed'),html:node.outerHTML})),target:document.querySelector('.react-flow__node[data-id=b]')?.outerHTML.slice(0,1500)})`));throw error;}); await evaluate(`document.querySelector('[data-resize-toggle="b"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');return !!node&&[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].every(view=>view.modes.get('b')!=='resize')&&!node.querySelector('[data-resize-handle]')&&!document.querySelector('[data-resize-toggle="b"][aria-pressed="true"]');})()`,'toolbar did not dismiss its own resize mode').catch(async error=>{console.log('MODE DIAGNOSTIC',await evaluate(`JSON.stringify({views:[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].map(view=>({id:view.id,modes:[...view.modes]})),buttons:[...document.querySelectorAll('[data-resize-toggle="b"]')].map(node=>({pressed:node.getAttribute('aria-pressed'),html:node.outerHTML})),target:document.querySelector('.react-flow__node[data-id=b]')?.outerHTML.slice(0,1500)})`));throw error;});
// 真实双击进入文本,IME 组合再提交中文;undo 应只撤销该输入,不回退此前已提交的缩放。
const textPoint = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+60,y:r.y+30}})()`); const textPoint = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+60,y:r.y+30}})()`);
await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...textPoint, button: 'left', buttons: 1, clickCount: 2 }); await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...textPoint, button: 'left', buttons: 1, clickCount: 2 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', ...textPoint, button: 'left', buttons: 0, clickCount: 2 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', ...textPoint, button: 'left', buttons: 0, clickCount: 2 });
@@ -379,17 +298,15 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(documents.get(root).elements.get('b').data.text,'Editable content');assert.equal(documents.get(root).elements.get('b').geometry.width,300); assert.equal(documents.get(root).elements.get('b').data.text,'Editable content');assert.equal(documents.get(root).elements.get('b').geometry.width,300);
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'edits did not settle before hit-test'); await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'edits did not settle before hit-test');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 沿可见曲线法线寻找落在 pane 的点,特意避开节点并超出原生 SVG 交互 stroke,检验屏幕容差命中。 // Hit from the pane beyond the native edge interaction stroke, using screen-space tolerance.
const nearCurve=await evaluate(`(()=>{const path=document.querySelector('.react-flow__edge[data-id="edge"] .react-flow__edge-path');if(!path)return null;const length=path.getTotalLength(),matrix=path.getScreenCTM();for(let t=.1;t<.99;t+=.01){const a=path.getPointAtLength(length*t),b=path.getPointAtLength(length*t+1),p=new DOMPoint(a.x,a.y).matrixTransform(matrix),q=new DOMPoint(b.x,b.y).matrixTransform(matrix),dx=q.x-p.x,dy=q.y-p.y,d=Math.hypot(dx,dy);for(const sign of [-1,1]){const x=p.x-sign*dy/d*11,y=p.y+sign*dx/d*11;if(document.elementFromPoint(x,y)?.classList.contains('react-flow__pane')&&![...document.querySelectorAll('.react-flow__node')].some(node=>{const r=node.getBoundingClientRect();return x>=r.left-13&&x<=r.right+13&&y>=r.top-13&&y<=r.bottom+13;}))return{x,y};}}return null;})()`); const nearCurve=await evaluate(`(()=>{const path=document.querySelector('.react-flow__edge[data-id="edge"] .react-flow__edge-path');if(!path)return null;const length=path.getTotalLength(),matrix=path.getScreenCTM();for(let t=.1;t<.99;t+=.01){const a=path.getPointAtLength(length*t),b=path.getPointAtLength(length*t+1),p=new DOMPoint(a.x,a.y).matrixTransform(matrix),q=new DOMPoint(b.x,b.y).matrixTransform(matrix),dx=q.x-p.x,dy=q.y-p.y,d=Math.hypot(dx,dy);for(const sign of [-1,1]){const x=p.x-sign*dy/d*11,y=p.y+sign*dx/d*11;if(document.elementFromPoint(x,y)?.classList.contains('react-flow__pane')&&![...document.querySelectorAll('.react-flow__node')].some(node=>{const r=node.getBoundingClientRect();return x>=r.left-13&&x<=r.right+13&&y>=r.top-13&&y<=r.bottom+13;}))return{x,y};}}return null;})()`);
assert.ok(nearCurve,'no pane point beyond the SVG interaction stroke'); assert.ok(nearCurve,'no pane point beyond the SVG interaction stroke');
// 边命中是只读选择:出现曲线控制点但不得产生文档 operation。
const beforeHit=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length; const beforeHit=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
await call('Input.dispatchMouseEvent',{type:'mousePressed',...nearCurve,button:'left',buttons:1,clickCount:1}); await call('Input.dispatchMouseEvent',{type:'mousePressed',...nearCurve,button:'left',buttons:1,clickCount:1});
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...nearCurve,button:'left',buttons:0,clickCount:1}); await call('Input.dispatchMouseEvent',{type:'mouseReleased',...nearCurve,button:'left',buttons:0,clickCount:1});
await until(`!!document.querySelector('[aria-label="曲线点 1"]')`,'pane near-curve hit did not select the edge'); await until(`!!document.querySelector('[aria-label="曲线点 1"]')`,'pane near-curve hit did not select the edge');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeHit,'read-only hit query created a document operation'); assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeHit,'read-only hit query created a document operation');
console.log('PASS screen-tolerance pane hit selects the displayed curve beyond native SVG stroke without a model operation'); console.log('PASS screen-tolerance pane hit selects the displayed curve beyond native SVG stroke without a model operation');
// 重叠区域控制点仍需可命中,拖动末坐标保存为 (440,265),整个曲线手势只新增一次请求。
const curveHandle=await evaluate(`(()=>{const r=document.querySelector('[aria-label="曲线点 1"]').getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2};})()`); const curveHandle=await evaluate(`(()=>{const r=document.querySelector('[aria-label="曲线点 1"]').getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2};})()`);
assert.equal(await evaluate(`document.elementFromPoint(${curveHandle.x},${curveHandle.y})?.getAttribute('aria-label')`),'曲线点 1','selected curve control must remain hit-testable where a card overlaps'); assert.equal(await evaluate(`document.elementFromPoint(${curveHandle.x},${curveHandle.y})?.getAttribute('aria-label')`),'曲线点 1','selected curve control must remain hit-testable where a card overlaps');
const beforeCurve = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length; const beforeCurve = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
@@ -403,7 +320,6 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50); await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'curve edit did not settle before opening recovery editor'); await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'curve edit did not settle before opening recovery editor');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 在 b 的实际可见 label 中扫描 elementFromPoint,找未被遮挡的原生双击位置,避免脚本 focus 跳过页面编辑流程。
const recoveryPoint=await evaluate(`(()=>{const label=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]'),r=label.getBoundingClientRect(); const recoveryPoint=await evaluate(`(()=>{const label=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]'),r=label.getBoundingClientRect();
for(let y=Math.max(0,r.top)+8;y<Math.min(innerHeight,r.bottom);y+=8)for(let x=Math.max(0,r.left)+8;x<Math.min(innerWidth,r.right);x+=8) for(let y=Math.max(0,r.top)+8;y<Math.min(innerHeight,r.bottom);y+=8)for(let x=Math.max(0,r.left)+8;x<Math.min(innerWidth,r.right);x+=8)
if(label.contains(document.elementFromPoint(x,y)))return{x,y}; if(label.contains(document.elementFromPoint(x,y)))return{x,y};
@@ -411,77 +327,57 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
})()`); })()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...recoveryPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...recoveryPoint,button:'left',buttons:0,clickCount:2}); await call('Input.dispatchMouseEvent',{type:'mousePressed',...recoveryPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...recoveryPoint,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'recovery edit did not open'); await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'recovery edit did not open');
// 下一写入明确拒绝并由 fixture 改出远程值;本地文字保留,页面应显示可进入恢复的动作。
rejectNextEdit=true;await call('Input.insertText',{text:'Local recovery'});await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected content did not expose recovery'); rejectNextEdit=true;await call('Input.insertText',{text:'Local recovery'});await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected content did not expose recovery');
// 保存本地候选文字和原 rejected requestId;恢复选择必须呈现 Remote reviewed 作为用户审阅基线。
const localRecoveryText=await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value`),rejectedId=rejectedEdit.request_id; const localRecoveryText=await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value`),rejectedId=rejectedEdit.request_id;
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')`,'recovery choices did not load'); await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')`,'recovery choices did not load');
assert.ok(await evaluate(`document.querySelector('[role=dialog]').innerText.includes('Remote reviewed')`),'review omitted the server value'); assert.ok(await evaluate(`document.querySelector('[role=dialog]').innerText.includes('Remote reviewed')`),'review omitted the server value');
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);
// 选择 keep-local 后先预览,按钮可用才 apply;等待对话框关闭与服务端新内容,不用 UI 关闭代替数据核对。
await click('预览处理方案');await until(`!document.querySelector('[aria-label="应用处理方案"]').disabled`,'reviewed recovery plan did not become applicable');await click('应用处理方案'); await click('预览处理方案');await until(`!document.querySelector('[aria-label="应用处理方案"]').disabled`,'reviewed recovery plan did not become applicable');await click('应用处理方案');
await until(`!document.querySelector('[role=dialog]')`,'recovery dialog did not close after persistence'); await until(`!document.querySelector('[role=dialog]')`,'recovery dialog did not close after persistence');
for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!==localRecoveryText;i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,localRecoveryText); for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!==localRecoveryText;i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,localRecoveryText);
// 替代请求必须有新 requestId/operationId,条件 before 是已审阅远程值;下一次 undo 也必须回到这个远程基线。
const recompiled=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).at(-1),recompiledBody=JSON.parse(recompiled.bytes.toString());assert.notEqual(recompiledBody.request_id,rejectedId);assert.notEqual(recompiledBody.operations[0].id,rejectedEdit.operations[0].id);assert.equal(recompiledBody.operations[0].changes[0].before,'Remote reviewed'); const recompiled=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).at(-1),recompiledBody=JSON.parse(recompiled.bytes.toString());assert.notEqual(recompiledBody.request_id,rejectedId);assert.notEqual(recompiledBody.operations[0].id,rejectedEdit.operations[0].id);assert.equal(recompiledBody.operations[0].changes[0].before,'Remote reviewed');
await click('撤销');for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Remote reviewed';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Remote reviewed'); await click('撤销');for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Remote reviewed';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Remote reviewed');
console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline'); console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline');
// 改测结构创建拒绝:界面要标明创建元素和关联结构变更,应用后保留该新元素的撤销能力。
rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id; rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id;
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change'); await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change');
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));rejectNextEdit=true;await click('撤销');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected undo did not expose recovery'); await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));rejectNextEdit=true;await click('撤销');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected undo did not expose recovery');
// 再拒绝该创建的 undo,恢复文案要识别为未同步撤销,选择保留撤销而不是重新创建普通内容操作。
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('未同步的撤销')`,'undo review did not identify the history action');assert.ok(await evaluate(`document.querySelector('select[aria-label="处理第1项修改"]').innerText.includes('保留这次撤销')`)); await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('未同步的撤销')`,'undo review did not identify the history action');assert.ok(await evaluate(`document.querySelector('select[aria-label="处理第1项修改"]').innerText.includes('保留这次撤销')`));
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('重做记录由')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'undo recovery omitted its redo stack');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'undo recovery did not finish');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false); await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('重做记录由')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'undo recovery omitted its redo stack');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'undo recovery did not finish');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
// 恢复撤销后 redo 以第二代还原,再 undo 删除;检查的是栈方向和生命期,不只是节点是否存在。
await click('重做');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.get(createdId).incarnation,2);await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false); await click('重做');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.get(createdId).incarnation,2);await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
console.log('PASS actual rejected undo review labels the traversal, previews both stacks, replans deletion and retains redo with a new lifecycle'); console.log('PASS actual rejected undo review labels the traversal, previews both stacks, replans deletion and retains redo with a new lifecycle');
console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo'); console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo');
// 辅助动作先原生点击空白清选择,再选 b 下缘并等 AI 工具可用;避免复用上个场景的选中状态。
const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');}; const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');};
// 从实际 AI 面板读取 fixture 模型,设置 prompt 后执行;受理可见时服务只应创建一个任务。
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host'); await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host');
await evaluate(`(()=>{const input=document.querySelector('[aria-label="AI 指令"]');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'Summarize this content');input.dispatchEvent(new Event('input',{bubbles:true}));})()`); await evaluate(`(()=>{const input=document.querySelector('[aria-label="AI 指令"]');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'Summarize this content');input.dispatchEvent(new Event('input',{bubbles:true}));})()`);
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI command did not become executable');await click('执行 AI'); await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI command did not become executable');await click('执行 AI');
await until(`document.body.innerText.includes('已受理')`,'AI admission did not persist');assert.equal(aiRuns.size,1); await until(`document.body.innerText.includes('已受理')`,'AI admission did not persist');assert.equal(aiRuns.size,1);
// 记录 CSS zoom、内容宽高和字号,后续比较这些具体样式值;没有在此做完整像素或全部排版属性的等价检查。
const visualMetric=`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]'),frame=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return {zoom:frame.style.zoom,width:frame.style.width,height:frame.style.height,font:getComputedStyle(node.querySelector('textarea')).fontSize}})()`,beforeVisual=await evaluate(visualMetric); const visualMetric=`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]'),frame=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return {zoom:frame.style.zoom,width:frame.style.width,height:frame.style.height,font:getComputedStyle(node.querySelector('textarea')).fontSize}})()`,beforeVisual=await evaluate(visualMetric);
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);await until(`location.pathname.endsWith('${other}')&&document.querySelectorAll('.react-flow__node').length===0`,'AI view detach failed'); await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);await until(`location.pathname.endsWith('${other}')&&document.querySelectorAll('.react-flow__node').length===0`,'AI view detach failed');
// 导航到另一个项目后,由 fixture 在根项目创建缩放分区并移动 b,保留其元素身份,再追加生成结果事件。
const run=aiRuns.get(id(700)),beforeAI=documents.get(root),beforeNode=beforeAI.elements.get('b'); const run=aiRuns.get(id(700)),beforeAI=documents.get(root),beforeNode=beforeAI.elements.get('b');
const frame={...node('relocation-frame',root,30),componentId:'frame',geometry:{x:30,y:50,width:1000,height:600,rotation:0},data:{title:'Relocated partition',text:'',presentation:{contentScale:2}}}; const frame={...node('relocation-frame',root,30),componentId:'frame',geometry:{x:30,y:50,width:1000,height:600,rotation:0},data:{title:'Relocated partition',text:'',presentation:{contentScale:2}}};
// 按新 owner 的局部坐标换算几何并写 unitScale=.5;这是测试准备的已知变换,不由浏览器拖拽自动产生。
const movedModel=applyChanges(beforeAI,[{kind:'element',id:frame.id,before:null,after:frame},{kind:'canvas',id:partition,before:null,after:{id:partition,parent:{canvasId:root,entryElementId:frame.id}}},{kind:'field',target:{id:'b',incarnation:1},field:'ownerCanvasId',before:root,after:partition},{kind:'field',target:{id:'b',incarnation:1},field:'geometry',before:beforeNode.geometry,after:{...beforeNode.geometry,x:(beforeNode.geometry.x-54)/2,y:(beforeNode.geometry.y-86)/2,width:beforeNode.geometry.width/2,height:beforeNode.geometry.height/2}},{kind:'field',target:{id:'b',incarnation:1},field:'data.presentation',before:beforeNode.data.presentation??null,beforePresent:Object.hasOwn(beforeNode.data,'presentation'),after:{...beforeNode.data.presentation,unitScale:.5},afterPresent:true}]); const movedModel=applyChanges(beforeAI,[{kind:'element',id:frame.id,before:null,after:frame},{kind:'canvas',id:partition,before:null,after:{id:partition,parent:{canvasId:root,entryElementId:frame.id}}},{kind:'field',target:{id:'b',incarnation:1},field:'ownerCanvasId',before:root,after:partition},{kind:'field',target:{id:'b',incarnation:1},field:'geometry',before:beforeNode.geometry,after:{...beforeNode.geometry,x:(beforeNode.geometry.x-54)/2,y:(beforeNode.geometry.y-86)/2,width:beforeNode.geometry.width/2,height:beforeNode.geometry.height/2}},{kind:'field',target:{id:'b',incarnation:1},field:'data.presentation',before:beforeNode.data.presentation??null,beforePresent:Object.hasOwn(beforeNode.data,'presentation'),after:{...beforeNode.data.presentation,unitScale:.5},afterPresent:true}]);
const generated=applyChanges(movedModel,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:beforeNode.data.text,after:'Generated while another canvas was open',beforePresent:true,afterPresent:true}]);documents.set(root,generated);versions.set(root,versions.get(root)+1);versions.set(partition,2);run.status='succeeded'; const generated=applyChanges(movedModel,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:beforeNode.data.text,after:'Generated while another canvas was open',beforePresent:true,afterPresent:true}]);documents.set(root,generated);versions.set(root,versions.get(root)+1);versions.set(partition,2);run.status='succeeded';
run.events.push({seq:3,run_id:id(700),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(701),kind:'text',text:'Generated while another canvas was open'}},{seq:4,run_id:id(700),type:'step.applied',step_id:'step-b',board_id:partition,origin_board_id:root,canonical_root:root,node_id:'b',canvas_version:versions.get(partition),document_format_version:1,target_incarnation:1,model:model(generated,true)},{seq:5,run_id:id(700),type:'run.completed',status:'succeeded'}); run.events.push({seq:3,run_id:id(700),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(701),kind:'text',text:'Generated while another canvas was open'}},{seq:4,run_id:id(700),type:'step.applied',step_id:'step-b',board_id:partition,origin_board_id:root,canonical_root:root,node_id:'b',canvas_version:versions.get(partition),document_format_version:1,target_incarnation:1,model:model(generated,true)},{seq:5,run_id:id(700),type:'run.completed',status:'succeeded'});
// 离开视图后仍应继续轮询持久任务;另一项目页面必须保持无节点,不能显示原项目晚到结果。
const pollsBefore=calls.filter(call=>call.path.includes('/ai/runs/')).length; const pollsBefore=calls.filter(call=>call.path.includes('/ai/runs/')).length;
for(let i=0;i<200&&calls.filter(call=>call.path.includes('/ai/runs/')).length===pollsBefore;i++)await pause(20); for(let i=0;i<200&&calls.filter(call=>call.path.includes('/ai/runs/')).length===pollsBefore;i++)await pause(20);
assert.ok(calls.filter(call=>call.path.includes('/ai/runs/')).length>pollsBefore,'view detach stopped persistent AI polling');await pause(200); assert.ok(calls.filter(call=>call.path.includes('/ai/runs/')).length>pollsBefore,'view detach stopped persistent AI polling');await pause(200);
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'),0,'AI output leaked into a different project view'); assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'),0,'AI output leaked into a different project view');
await call('Page.reload');await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'other project reload failed'); await call('Page.reload');await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'other project reload failed');
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);await until(`!!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelector('.react-flow__node[data-id=b] textarea')?.value==='Generated while another canvas was open'`,'AI output did not survive reload'); await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);await until(`!!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelector('.react-flow__node[data-id=b] textarea')?.value==='Generated while another canvas was open'`,'AI output did not survive reload');
// 重载并返回原项目,面板要恢复 RunRecord;核对仅一个生成 POST、一个 b,以及缩放前后相同视觉采样。
await selectB();await click('AI 创作');await until(`document.body.innerText.includes('已更新当前元素')`,'durable RunRecord did not recover');await click('关闭 AI 工具'); await selectB();await click('AI 创作');await until(`document.body.innerText.includes('已更新当前元素')`,'durable RunRecord did not recover');await click('关闭 AI 工具');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,150);assert.equal(documents.get(root).elements.get('b').data.presentation.unitScale,.5);assert.deepEqual(await evaluate(visualMetric),beforeVisual,'normalized units changed actual rendered size or typography');assert.equal(documents.get(root).elements.get('b').ownerCanvasId,partition);assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node[data-id=b]').length`),1); assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,150);assert.equal(documents.get(root).elements.get('b').data.presentation.unitScale,.5);assert.deepEqual(await evaluate(visualMetric),beforeVisual,'normalized units changed actual rendered size or typography');assert.equal(documents.get(root).elements.get('b').ownerCanvasId,partition);assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node[data-id=b]').length`),1);
console.log('PASS actual AI panel follows a scaled owner move with stable rendered size/typography, polls after navigation, and restores one element/RunRecord without re-execution'); console.log('PASS actual AI panel follows a scaled owner move with stable rendered size/typography, polls after navigation, and restores one element/RunRecord without re-execution');
// 下一任务走实际取消按钮,完成后保留当前内容,再启动第三个任务构造可显式选择的冲突输出。
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="执行 AI"]')&&!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI action did not reopen');await click('执行 AI');await until(`!!document.querySelector('[aria-label="取消生成"]')`,'cancel control did not appear');await click('取消生成');await until(`document.body.innerText.includes('已取消生成')`,'durable cancellation did not finish');assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open'); await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="执行 AI"]')&&!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI action did not reopen');await click('执行 AI');await until(`!!document.querySelector('[aria-label="取消生成"]')`,'cancel control did not appear');await click('取消生成');await until(`document.body.innerText.includes('已取消生成')`,'durable cancellation did not finish');assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'cancelled run did not release execution');await click('执行 AI'); await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'cancelled run did not release execution');await click('执行 AI');
for(let i=0;i<100&&!aiRuns.has(id(720));i++)await pause(20);assert.ok(aiRuns.has(id(720))); for(let i=0;i<100&&!aiRuns.has(id(720));i++)await pause(20);assert.ok(aiRuns.has(id(720)));
// fixture 注入 output/conflict/completed 事件;冲突只保留结果,不应自动覆盖当前元素。
const conflicted=aiRuns.get(id(720));conflicted.status='conflict';conflicted.events.push({seq:3,run_id:id(720),type:'step.output',step_id:'step-b',board_id:partition,node_id:'b',output:{output_id:id(721),kind:'text',text:'Chosen conflict result'}},{seq:4,run_id:id(720),type:'step.conflict',step_id:'step-b',board_id:partition,node_id:'b',status:'conflict',error:'target_changed'},{seq:5,run_id:id(720),type:'run.completed',status:'conflict'}); const conflicted=aiRuns.get(id(720));conflicted.status='conflict';conflicted.events.push({seq:3,run_id:id(720),type:'step.output',step_id:'step-b',board_id:partition,node_id:'b',output:{output_id:id(721),kind:'text',text:'Chosen conflict result'}},{seq:4,run_id:id(720),type:'step.conflict',step_id:'step-b',board_id:partition,node_id:'b',status:'conflict',error:'target_changed'},{seq:5,run_id:id(720),type:'run.completed',status:'conflict'});
await until(`[...document.querySelectorAll('button')].some(button=>button.textContent==='使用此结果替换当前内容')`,'conflict result did not expose explicit application');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent==='使用此结果替换当前内容').click()`); await until(`[...document.querySelectorAll('button')].some(button=>button.textContent==='使用此结果替换当前内容')`,'conflict result did not expose explicit application');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent==='使用此结果替换当前内容').click()`);
// 选择输出后本地立即变化;applyGate 尚未释放时先 undo,应立即恢复旧内容,再允许服务回执协调。
await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Chosen conflict result'`,'chosen output was not optimistic');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Generated while another canvas was open'`,'unknown apply could not be locally undone');releaseApply(); await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Chosen conflict result'`,'chosen output was not optimistic');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Generated while another canvas was open'`,'unknown apply could not be locally undone');releaseApply();
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Generated while another canvas was open';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');await click('重做'); for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Generated while another canvas was open';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');await click('重做');
// redo 恢复已选择输出,不重新生成或重复 apply;最后精确检查取消一次、应用一次、总受理三次。
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Chosen conflict result';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Chosen conflict result');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/apply')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,3);await click('关闭 AI 工具'); for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Chosen conflict result';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Chosen conflict result');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/apply')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,3);await click('关闭 AI 工具');
console.log('PASS actual AI cancellation and optimistic conflict application use durable actions; undo/redo while awaiting acknowledgment never regenerate'); console.log('PASS actual AI cancellation and optimistic conflict application use durable actions; undo/redo while awaiting acknowledgment never regenerate');
// 在页面生成真实 4×2 PNG 并经 File input 导入;上传 gate 仍挂起,本地 Blob URL 和尺寸应先出现。
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload'); await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
// 从 Blob 预览找到新元素,等待 height=130;比例操作可用后,一次 undo 应删除整个已补尺寸创建,redo 恢复尺寸。
const pendingImageId=await evaluate(`document.querySelector('[data-headless-canvas] img[src^="blob:"]').closest('.react-flow__node').getAttribute('data-id')`); const pendingImageId=await evaluate(`document.querySelector('[data-headless-canvas] img[src^="blob:"]').closest('.react-flow__node').getAttribute('data-id')`);
await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'default image size did not automatically use locally decoded 4:2 aspect ratio before upload ACK'); await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'default image size did not automatically use locally decoded 4:2 aspect ratio before upload ACK');
await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整'); await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');
@@ -489,91 +385,72 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await click('重做');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')?.style.height==='130px'`,'redo lost the enriched creation dimensions');assert.equal(uploads.length<=1,true); await click('重做');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')?.style.height==='130px'`,'redo lost the enriched creation dimensions');assert.equal(uploads.length<=1,true);
await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="${pendingImageId}"]');const content=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return content?.style.height==='130px';})()`,'image content retained a stale measured height after automatic sizing and redo'); await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="${pendingImageId}"]');const content=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return content?.style.height==='130px';})()`,'image content retained a stale measured height after automatic sizing and redo');
console.log('PASS default image sizing precedes upload ACK; creation undo/redo includes decoded dimensions without a second history unit or upload'); console.log('PASS default image sizing precedes upload ACK; creation undo/redo includes decoded dimensions without a second history unit or upload');
// 截图是此时 pending 图片状态的辅助产物,随后另外核对上游上传数与文档元素数。
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64')); const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64'));
for (let i = 0; i < 100 && !uploads.length; i++) await pause(20); for (let i = 0; i < 100 && !uploads.length; i++) await pause(20);
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5); assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5);
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`); await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);
await until(`location.pathname.endsWith('${other}') && !!document.querySelector('[data-headless-canvas]') && document.querySelectorAll('.react-flow__node').length === 0`, 'other board did not open'); await until(`location.pathname.endsWith('${other}') && !!document.querySelector('[data-headless-canvas]') && document.querySelectorAll('.react-flow__node').length === 0`, 'other board did not open');
// 导航到另一 board 后才确认上传;原项目后台继续写图片,当前页面不得被其晚到响应污染。
releaseUpload(); releaseUpload();
for (let i = 0; i < 200 && documents.get(root).elements.size !== 6; i++) await pause(20); for (let i = 0; i < 200 && documents.get(root).elements.size !== 6; i++) await pause(20);
assert.equal(documents.get(root).elements.size, 6, 'view detach aborted the upload or document operation'); assert.equal(documents.get(root).elements.size, 6, 'view detach aborted the upload or document operation');
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'), 0, 'late result appeared on the wrong board'); assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'), 0, 'late result appeared on the wrong board');
// 另一项目用资源选择器采用已上传身份;先验证每次采用都重新授权,拒绝时不能创建元素或 undo 单元。
await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'other opened project image did not appear in the picker'); await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'other opened project image did not appear in the picker');
const pickerScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-resource-picker.png',Buffer.from(pickerScreenshot.data,'base64')); const pickerScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-resource-picker.png',Buffer.from(pickerScreenshot.data,'base64'));
denyAssetMetadata=true;await click('添加图片:视觉参考');await until(`document.querySelector('[role="dialog"] [role="alert"]')?.textContent.includes('访问权限')`,'fresh authorization rejection was not readable');assert.equal(documents.get(other).elements.size,0);assert.equal(await evaluate(`document.querySelector('[aria-label="撤销"]').disabled`),true);denyAssetMetadata=false; denyAssetMetadata=true;await click('添加图片:视觉参考');await until(`document.querySelector('[role="dialog"] [role="alert"]')?.textContent.includes('访问权限')`,'fresh authorization rejection was not readable');assert.equal(documents.get(other).elements.size,0);assert.equal(await evaluate(`document.querySelector('[aria-label="撤销"]').disabled`),true);denyAssetMetadata=false;
// 授权读取挂起时按 Escape 关闭选择器,再放行旧响应;已关闭会话不得接纳这个迟到结果。
metadataWait=new Promise(resolve=>releaseMetadata=resolve);const metadataCalls=calls.filter(call=>call.path.includes('/assets/metadata/')).length;await click('添加图片:视觉参考');for(let i=0;i<100&&calls.filter(call=>call.path.includes('/assets/metadata/')).length===metadataCalls;i++)await pause(20);assert.ok(calls.filter(call=>call.path.includes('/assets/metadata/')).length>metadataCalls,'picker did not start authorization');await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`!document.querySelector('[role="dialog"]')`,'Escape did not close the resource picker');releaseMetadata();metadataWait=undefined;await pause(100);assert.equal(documents.get(other).elements.size,0,'closed picker admitted a late authorization result'); metadataWait=new Promise(resolve=>releaseMetadata=resolve);const metadataCalls=calls.filter(call=>call.path.includes('/assets/metadata/')).length;await click('添加图片:视觉参考');for(let i=0;i<100&&calls.filter(call=>call.path.includes('/assets/metadata/')).length===metadataCalls;i++)await pause(20);assert.ok(calls.filter(call=>call.path.includes('/assets/metadata/')).length>metadataCalls,'picker did not start authorization');await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`!document.querySelector('[role="dialog"]')`,'Escape did not close the resource picker');releaseMetadata();metadataWait=undefined;await pause(100);assert.equal(documents.get(other).elements.size,0,'closed picker admitted a late authorization result');
// 重新打开后成功采用同一 assetId,比例来自授权元数据且上传总数仍为一;再检查 undo/redo 进入第二代。
await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'resource picker did not reopen');await click('添加图片:视觉参考');await until(`!document.querySelector('[role="dialog"]')&&document.querySelectorAll('.react-flow__node').length===1`,'adopted image did not appear');for(let i=0;i<100&&documents.get(other).elements.size!==1;i++)await pause(20);const adopted=[...documents.get(other).elements.values()][0];assert.equal(adopted.data.assetId,uploads[0].id);assert.equal(uploads.length,1,'cross-project picker uploaded a duplicate');assert.equal(adopted.geometry.width/adopted.geometry.height,2); await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'resource picker did not reopen');await click('添加图片:视觉参考');await until(`!document.querySelector('[role="dialog"]')&&document.querySelectorAll('.react-flow__node').length===1`,'adopted image did not appear');for(let i=0;i<100&&documents.get(other).elements.size!==1;i++)await pause(20);const adopted=[...documents.get(other).elements.values()][0];assert.equal(adopted.data.assetId,uploads[0].id);assert.equal(uploads.length,1,'cross-project picker uploaded a duplicate');assert.equal(adopted.geometry.width/adopted.geometry.height,2);
await click('撤销');for(let i=0;i<100&&documents.get(other).elements.size;i++)await pause(20);assert.equal(documents.get(other).elements.size,0);await click('重做');for(let i=0;i<100&&documents.get(other).elements.get(adopted.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(other).elements.get(adopted.id).incarnation,2);assert.equal(uploads.length,1);console.log('PASS cross-project resource picker reauthorizes, cancels a late result, creates one shared image and supports undo/redo without uploading again'); await click('撤销');for(let i=0;i<100&&documents.get(other).elements.size;i++)await pause(20);assert.equal(documents.get(other).elements.size,0);await click('重做');for(let i=0;i<100&&documents.get(other).elements.get(adopted.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(other).elements.get(adopted.id).incarnation,2);assert.equal(uploads.length,1);console.log('PASS cross-project resource picker reauthorizes, cancels a late result, creates one shared image and supports undo/redo without uploading again');
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`); await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);
// 返回原项目并重载,文档/资源身份继续存在且没有退回 legacy canvas 读取。
await until('document.querySelectorAll(".react-flow__node").length === 5', 'returning project lost its engine'); await until('document.querySelectorAll(".react-flow__node").length === 5', 'returning project lost its engine');
assert.equal(uploads.length, 1); assert.equal(uploads.length, 1);
await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 5', 'page reload did not recover the document'); await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 5', 'page reload did not recover the document');
assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false); assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false);
console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity'); console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity');
console.log('PASS canvas floating controls remain visible while the sidebar is expanded'); console.log('PASS canvas floating controls remain visible while the sidebar is expanded');
// 完整页面模式也调用独立 HookChecks,报告来自真实 React mount 与内存端口组合,具体步骤在 hooks fixture 中说明。
const hookReports=await evaluate('window.__runCanvasHookChecks()');for(const report of hookReports)console.log('PASS React hooks: '+report); const hookReports=await evaluate('window.__runCanvasHookChecks()');for(const report of hookReports)console.log('PASS React hooks: '+report);
// 导入 NDJSON 含一条合法记录和一个坏尾行;合法项先本地加入,服务写入仍由 importGate 挂住。
const beforeImport=documents.get(root).elements.size,importRecord={kind:'element',key:'ui-import',record:{id:'ui-import',incarnation:1,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:'Imported NDJSON node',text:'Visible before ACK'},geometry:{x:700,y:450,width:240,height:160,rotation:0}}},nativeText=JSON.stringify(importRecord)+'\n{bad}'; const beforeImport=documents.get(root).elements.size,importRecord={kind:'element',key:'ui-import',record:{id:'ui-import',incarnation:1,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:'Imported NDJSON node',text:'Visible before ACK'},geometry:{x:700,y:450,width:240,height:160,rotation:0}}},nativeText=JSON.stringify(importRecord)+'\n{bad}';
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(nativeText)}],'canvas.ndjson',{type:'application/x-ndjson'}));const input=document.querySelector('[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(nativeText)}],'canvas.ndjson',{type:'application/x-ndjson'}));const input=document.querySelector('[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length===6&&document.body.innerText.includes('部分加入')`,'native import did not show its successful prefix and partial report'); await until(`document.querySelectorAll('.react-flow__node').length===6&&document.body.innerText.includes('部分加入')`,'native import did not show its successful prefix and partial report');
// 确认网络已进入屏障但服务元素数未增加,页面却已有新节点和 partial 报告,区分显示与 ACK。
for(let i=0;i<100&&!importWriteSeen;i++)await pause(20);assert.ok(importWriteSeen);assert.equal(documents.get(root).elements.size,beforeImport,'import waited for its backend before displaying content'); for(let i=0;i<100&&!importWriteSeen;i++)await pause(20);assert.ok(importWriteSeen);assert.equal(documents.get(root).elements.size,beforeImport,'import waited for its backend before displaying content');
// 展开失败详情检查可读原因,截图后才释放文档 gate;重载要同时恢复内容与 partial 作业报告。
await evaluate(`[...document.querySelectorAll('summary')].find(summary=>summary.textContent.includes('条未加入记录')).click()`);await until(`document.body.innerText.includes('记录格式错误')`,'import failure reason was hidden'); await evaluate(`[...document.querySelectorAll('summary')].find(summary=>summary.textContent.includes('条未加入记录')).click()`);await until(`document.body.innerText.includes('记录格式错误')`,'import failure reason was hidden');
const importScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-integration.png',Buffer.from(importScreenshot.data,'base64'));releaseImport(); const importScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-integration.png',Buffer.from(importScreenshot.data,'base64'));releaseImport();
for(let i=0;i<200&&documents.get(root).elements.size!==beforeImport+1;i++)await pause(20);assert.equal(documents.get(root).elements.size,beforeImport+1);await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('部分加入')`,'import task and content did not recover after reload'); for(let i=0;i<200&&documents.get(root).elements.size!==beforeImport+1;i++)await pause(20);assert.equal(documents.get(root).elements.size,beforeImport+1);await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('部分加入')`,'import task and content did not recover after reload');
// 记录模型 POST 基数,通过临时 Host 公开动作清理导入源;UI 状态和重载结果要保留,但清理不能产生内容操作。
const beforeSourceCollection=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length; const beforeSourceCollection=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
await until(`typeof window.__headlessCollectSources==='function'`,'fixture Host action was not installed');const sourceCollection=await evaluate(`window.__headlessCollectSources()`);assert.equal(sourceCollection.purgedIds.length,1); await until(`typeof window.__headlessCollectSources==='function'`,'fixture Host action was not installed');const sourceCollection=await evaluate(`window.__headlessCollectSources()`);assert.equal(sourceCollection.purgedIds.length,1);
await evaluate(`(()=>{const details=document.querySelector('[aria-label="画布导入"] details');if(!details.open)details.querySelector('summary').click();})()`); await evaluate(`(()=>{const details=document.querySelector('[aria-label="画布导入"] details');if(!details.open)details.querySelector('summary').click();})()`);
await until(`document.body.innerText.includes('本地源文件已清理')`,'source collection status was not displayed');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeSourceCollection,'source GC created a document operation'); await until(`document.body.innerText.includes('本地源文件已清理')`,'source collection status was not displayed');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeSourceCollection,'source GC created a document operation');
const sourceScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-source-collection.png',Buffer.from(sourceScreenshot.data,'base64'));await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('本地源文件已清理')`,'retired source status/content did not recover'); const sourceScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-source-collection.png',Buffer.from(sourceScreenshot.data,'base64'));await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('本地源文件已清理')`,'retired source status/content did not recover');
console.log('PASS Host-triggered source collection displays its status, survives reload and creates zero model operations'); console.log('PASS Host-triggered source collection displays its status, survives reload and creates zero model operations');
// 源文件已经清理,undo 仍能移除成功前缀;终态导入作业重载后也不能复活已撤销记录。
await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import undo did not remove its successful content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import recovered an undone record'); await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import undo did not remove its successful content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import recovered an undone record');
console.log('PASS actual NDJSON import displays before backend ACK, reports a failed record, restores task/source state and never revives undone content'); console.log('PASS actual NDJSON import displays before backend ACK, reports a failed record, restores task/source state and never revives undone content');
await evaluate(`document.querySelector('.react-flow__node[data-id=b]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`); await evaluate(`document.querySelector('.react-flow__node[data-id=b]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await until(`!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'copy selection button remained disabled'); await until(`!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'copy selection button remained disabled');
// 工具栏复制走持久导入作业,准备完成后重载保持结果;undo 再重载不能由作业重放补回内容。
await click('深复制选中内容');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy did not publish through the durable task pipeline'); await click('深复制选中内容');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy did not publish through the durable task pipeline');
await until(`document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="正在准备复制"]')`,'copy preparation did not finish'); await until(`document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="正在准备复制"]')`,'copy preparation did not finish');
await pause(300);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy task did not recover after reload'); await pause(300);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy task did not recover after reload');
await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy history did not undo its published content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy replay revived an undone record'); await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy history did not undo its published content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy replay revived an undone record');
console.log('PASS actual deep copy toolbar action publishes a durable task, survives reload and does not revive its undone content'); console.log('PASS actual deep copy toolbar action publishes a durable task, survives reload and does not revive its undone content');
// portal 分区标题草稿由显式 overlay owner 持有,不以节点 mode 代表。 // Portalled frame-title drafts are not represented by a node editing mode.
await evaluate(`document.querySelector('.react-flow__node[data-id="relocation-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`); await evaluate(`document.querySelector('.react-flow__node[data-id="relocation-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await until(`!!document.querySelector('[aria-label="分区名称"]')`,'frame title editor did not open'); await until(`!!document.querySelector('[aria-label="分区名称"]')`,'frame title editor did not open');
// 折叠只影响投影:b 模型端点仍存在且上级连线仍画到分区锚点;展开后成员重新出现。
await click('收起子画布');await until(`!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelectorAll('.react-flow__edge').length>0`,'collapsed partition did not render its upper-canvas connection');assert.equal(documents.get(root).elements.get('edge').target.elementId,'b');assert.ok(documents.get(root).elements.has('b'));await click('展开子画布');await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'expanding partition did not restore the member'); await click('收起子画布');await until(`!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelectorAll('.react-flow__edge').length>0`,'collapsed partition did not render its upper-canvas connection');assert.equal(documents.get(root).elements.get('edge').target.elementId,'b');assert.ok(documents.get(root).elements.has('b'));await click('展开子画布');await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'expanding partition did not restore the member');
console.log('PASS collapsed partitions retain model endpoints and render upper-canvas connections through headless frame anchors'); console.log('PASS collapsed partitions retain model endpoints and render upper-canvas connections through headless frame anchors');
// portal 标题需显式注册 view/target;其工具栏按钮的普通 h 键不能被 canvas 工具路由抢走。
assert.ok(await evaluate(`document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]')?.dataset.canvasOverlayView`),'portalled frame title did not register its explicit view/target placement'); assert.ok(await evaluate(`document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]')?.dataset.canvasOverlayView`),'portalled frame title did not register its explicit view/target placement');
assert.equal(await evaluate(`(()=>{const overlay=document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]'),button=overlay.querySelector('button'),event=new KeyboardEvent('keydown',{key:'h',bubbles:true,cancelable:true});button.dispatchEvent(event);return event.defaultPrevented;})()`),false,'portalled toolbar button key was claimed by the canvas tool'); assert.equal(await evaluate(`(()=>{const overlay=document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]'),button=overlay.querySelector('button'),event=new KeyboardEvent('keydown',{key:'h',bubbles:true,cancelable:true});button.dispatchEvent(event);return event.defaultPrevented;})()`),false,'portalled toolbar button key was claimed by the canvas tool');
await evaluate(`(()=>{window.pinnedFrameTitle=document.querySelector('[aria-label="分区名称"]');window.pinnedFrameTitle.focus();window.pinnedFrameTitle.select();})()`);await call('Input.insertText',{text:'Pinned partition draft'}); await evaluate(`(()=>{window.pinnedFrameTitle=document.querySelector('[aria-label="分区名称"]');window.pinnedFrameTitle.focus();window.pinnedFrameTitle.select();})()`);await call('Input.insertText',{text:'Pinned partition draft'});
// 两个页面脚本分别大幅平移和回到原点,供 portal 与 IME retention 检查共用;不直接写业务几何。
const panFar=`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`,panHome=`(()=>{const matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:matrix.e/.5,deltaY:matrix.f/.5,bubbles:true,cancelable:true}));})()`; const panFar=`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`,panHome=`(()=>{const matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:matrix.e/.5,deltaY:matrix.f/.5,bubbles:true,cancelable:true}));})()`;
// 标题仍在编辑时把所属节点移出视口,要求相同 DOM、焦点和草稿保留;blur 保存后恢复裁剪,回视口再挂载。
await evaluate(panFar);await pause(250);assert.equal(await evaluate(`window.pinnedFrameTitle.isConnected&&document.activeElement===window.pinnedFrameTitle&&window.pinnedFrameTitle.value==='Pinned partition draft'`),true,'viewport culling discarded an unsaved frame title'); await evaluate(panFar);await pause(250);assert.equal(await evaluate(`window.pinnedFrameTitle.isConnected&&document.activeElement===window.pinnedFrameTitle&&window.pinnedFrameTitle.value==='Pinned partition draft'`),true,'viewport culling discarded an unsaved frame title');
await evaluate('window.pinnedFrameTitle.blur()');for(let i=0;i<100&&documents.get(root).elements.get('relocation-frame').data.title!=='Pinned partition draft';i++)await pause(20);assert.equal(documents.get(root).elements.get('relocation-frame').data.title,'Pinned partition draft'); await evaluate('window.pinnedFrameTitle.blur()');for(let i=0;i<100&&documents.get(root).elements.get('relocation-frame').data.title!=='Pinned partition draft';i++)await pause(20);assert.equal(documents.get(root).elements.get('relocation-frame').data.title,'Pinned partition draft');
await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after title blur');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'panning back did not remount the node'); await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after title blur');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'panning back did not remount the node');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`); await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);
// 文本 IME 组合态也要在离屏时保留同一编辑器;提交后 Escape 释放,再平移回来核对已保存文字。
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'text editor did not enter pinned mode');await evaluate('window.pinnedText=document.activeElement; void 0');await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1});await evaluate(panFar);await pause(250); await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'text editor did not enter pinned mode');await evaluate('window.pinnedText=document.activeElement; void 0');await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1});await evaluate(panFar);await pause(250);
assert.equal(await evaluate(`window.pinnedText.isConnected&&document.activeElement===window.pinnedText&&window.pinnedText.value.includes('保')`),true,'viewport culling interrupted active IME');await call('Input.insertText',{text:'保留输入'});for(let i=0;i<100&&!documents.get(root).elements.get('b').data.text.endsWith('保留输入');i++)await pause(20);assert.ok(documents.get(root).elements.get('b').data.text.endsWith('保留输入')); assert.equal(await evaluate(`window.pinnedText.isConnected&&document.activeElement===window.pinnedText&&window.pinnedText.value.includes('保')`),true,'viewport culling interrupted active IME');await call('Input.insertText',{text:'保留输入'});for(let i=0;i<100&&!documents.get(root).elements.get('b').data.text.endsWith('保留输入');i++)await pause(20);assert.ok(documents.get(root).elements.get('b').data.text.endsWith('保留输入'));
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after IME editing');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'edited node did not remount');assert.ok(await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value.endsWith('保留输入')`)); await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after IME editing');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'edited node did not remount');assert.ok(await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value.endsWith('保留输入')`));
console.log('PASS native viewport culling retains unsaved portalled frame titles and active IME across pan, then resumes and remounts saved content'); console.log('PASS native viewport culling retains unsaved portalled frame titles and active IME across pan, then resumes and remounts saved content');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`); await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'retiring editor did not focus'); await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'retiring editor did not focus');
// 删除仍有焦点的节点时不能等待 blur 才释放所有权;旧 textarea 脱离后应恢复裁剪,历史可重新还原内容。
assert.equal(await evaluate(`(()=>{window.__retiredEditor=document.activeElement;const engine=window.__fixtureActualEngine,element=engine.getSnapshot().document.elements.get('b');return engine.editor.dispatch({type:'element.remove',target:{id:element.id,incarnation:element.incarnation}}).status;})()`),'applied'); assert.equal(await evaluate(`(()=>{window.__retiredEditor=document.activeElement;const engine=window.__fixtureActualEngine,element=engine.getSnapshot().document.elements.get('b');return engine.editor.dispatch({type:'element.remove',target:{id:element.id,incarnation:element.incarnation}}).status;})()`),'applied');
await until(`!document.querySelector('.react-flow__node[data-id=b]')`,'focused node did not retire');assert.equal(await evaluate('window.__retiredEditor.isConnected'),false); await until(`!document.querySelector('.react-flow__node[data-id=b]')`,'focused node did not retire');assert.equal(await evaluate('window.__retiredEditor.isConnected'),false);
await evaluate(panFar);await until(`document.querySelectorAll('.react-flow__node').length===0`,'detached focus left the graph permanently retained'); await evaluate(panFar);await until(`document.querySelectorAll('.react-flow__node').length===0`,'detached focus left the graph permanently retained');
@@ -581,7 +458,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
console.log('PASS deleting a focused renderer releases detached ownership without requiring blur and history restores its content'); console.log('PASS deleting a focused renderer releases detached ownership without requiring blur and history restores its content');
// 4097 条边超过曲线检查预算,但仅三个端点/入口 owner 需要挂载;200 个无关离屏子卡应继续裁剪。 // Exceed the curve-inspection budget without retaining unrelated child cards.
const budgetRoot=id(870),budgetChild=id(871),budgetFrame={...node('budget-frame',budgetRoot,0),componentId:'frame',geometry:{x:0,y:400,width:1000,height:600,rotation:0}},budgetNodes=[node('budget-a',budgetRoot,80),node('budget-b',budgetRoot,450),budgetFrame,...Array.from({length:200},(_,index)=>node('budget-hidden-'+index,budgetChild,12000+index*280))],budgetEdges=Array.from({length:4097},(_,index)=>({id:'budget-edge-'+index,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:budgetRoot,source:{elementId:'budget-a'},target:{elementId:'budget-b'},data:{},path:null})); const budgetRoot=id(870),budgetChild=id(871),budgetFrame={...node('budget-frame',budgetRoot,0),componentId:'frame',geometry:{x:0,y:400,width:1000,height:600,rotation:0}},budgetNodes=[node('budget-a',budgetRoot,80),node('budget-b',budgetRoot,450),budgetFrame,...Array.from({length:200},(_,index)=>node('budget-hidden-'+index,budgetChild,12000+index*280))],budgetEdges=Array.from({length:4097},(_,index)=>({id:'budget-edge-'+index,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:budgetRoot,source:{elementId:'budget-a'},target:{elementId:'budget-b'},data:{},path:null}));
documents.set(budgetRoot,createDocument(budgetRoot,[{id:budgetRoot,parent:null},{id:budgetChild,parent:{canvasId:budgetRoot,entryElementId:budgetFrame.id}}],[...budgetNodes,...budgetEdges]));versions.set(budgetRoot,1);versions.set(budgetChild,1);boards.push({id:budgetRoot,workspace_id:workspace,kind:'board',version:1,title:'Curve budget board'}); documents.set(budgetRoot,createDocument(budgetRoot,[{id:budgetRoot,parent:null},{id:budgetChild,parent:{canvasId:budgetRoot,entryElementId:budgetFrame.id}}],[...budgetNodes,...budgetEdges]));versions.set(budgetRoot,1);versions.set(budgetChild,1);boards.push({id:budgetRoot,workspace_id:workspace,kind:'board',version:1,title:'Curve budget board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${budgetRoot}`}); await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${budgetRoot}`});
@@ -589,25 +466,21 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="budget-hidden-0"]')`),false);assert.equal(documents.get(budgetRoot).elements.size,4300);assert.equal(await evaluate('window.__fixtureActualEngine.getSnapshot().document.elements.size'),4300);assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get('budget-hidden-199')?.ownerCanvasId`),budgetChild); assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="budget-hidden-0"]')`),false);assert.equal(documents.get(budgetRoot).elements.size,4300);assert.equal(await evaluate('window.__fixtureActualEngine.getSnapshot().document.elements.size'),4300);assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get('budget-hidden-199')?.ownerCanvasId`),budgetChild);
console.log('PASS exhausted curve inspection retains all 4097 edges and their endpoint owners while 200 unrelated offscreen cards stay culled'); console.log('PASS exhausted curve inspection retains all 4097 edges and their endpoint owners while 200 unrelated offscreen cards stay culled');
// 构造未知 component、未来 dataVersion 和不透明 path/ports/presentation;明确区分已知外壳与无法解释的内部字段。 // Read a fresh project whose known envelope contains opaque component/version payloads.
const opaqueRoot=id(800),opaqueChild=id(801),opaqueU={...node('opaque-u',opaqueRoot,50),componentId:'plugin:future',dataVersion:9,data:{title:'Opaque source',ports:{find:'future field'},presentation:{unitScale:'future'},assetId:'opaque-asset',__headlessFallback:{handles:'untrusted'},opaque:{preserve:true}}},opaqueV={...node('opaque-v',opaqueRoot,400),dataVersion:99,data:{title:'Future card',text:{preserve:true},ports:'future field'}},opaqueFrame={...node('opaque-frame',opaqueRoot,100),componentId:'frame',dataVersion:99,geometry:{x:100,y:450,width:350,height:220,rotation:0},data:{title:'Future partition',ports:{preserve:true},presentation:{unitScale:'future'}}},opaqueKnown=node('opaque-known',opaqueRoot,800),opaqueMember=node('opaque-member',opaqueChild,20),opaqueEdge={id:'opaque-edge',incarnation:1,structure:'connection',componentId:'plugin:connection',dataVersion:9,ownerCanvasId:opaqueRoot,source:{elementId:opaqueU.id,portId:'custom-out'},target:{elementId:opaqueV.id,portId:'custom-in'},path:{type:'future',preserve:true},data:{appearance:{color:{preserve:true}}}},regularEdge={...opaqueEdge,id:'opaque-regular',componentId:'connection',dataVersion:1,target:{elementId:opaqueKnown.id,portId:'left'},path:null,data:{}},partitionEdge={...regularEdge,id:'opaque-partition',source:{elementId:opaqueKnown.id,portId:'bottom'},target:{elementId:opaqueMember.id}}; const opaqueRoot=id(800),opaqueChild=id(801),opaqueU={...node('opaque-u',opaqueRoot,50),componentId:'plugin:future',dataVersion:9,data:{title:'Opaque source',ports:{find:'future field'},presentation:{unitScale:'future'},assetId:'opaque-asset',__headlessFallback:{handles:'untrusted'},opaque:{preserve:true}}},opaqueV={...node('opaque-v',opaqueRoot,400),dataVersion:99,data:{title:'Future card',text:{preserve:true},ports:'future field'}},opaqueFrame={...node('opaque-frame',opaqueRoot,100),componentId:'frame',dataVersion:99,geometry:{x:100,y:450,width:350,height:220,rotation:0},data:{title:'Future partition',ports:{preserve:true},presentation:{unitScale:'future'}}},opaqueKnown=node('opaque-known',opaqueRoot,800),opaqueMember=node('opaque-member',opaqueChild,20),opaqueEdge={id:'opaque-edge',incarnation:1,structure:'connection',componentId:'plugin:connection',dataVersion:9,ownerCanvasId:opaqueRoot,source:{elementId:opaqueU.id,portId:'custom-out'},target:{elementId:opaqueV.id,portId:'custom-in'},path:{type:'future',preserve:true},data:{appearance:{color:{preserve:true}}}},regularEdge={...opaqueEdge,id:'opaque-regular',componentId:'connection',dataVersion:1,target:{elementId:opaqueKnown.id,portId:'left'},path:null,data:{}},partitionEdge={...regularEdge,id:'opaque-partition',source:{elementId:opaqueKnown.id,portId:'bottom'},target:{elementId:opaqueMember.id}};
const opaqueOriginal=createDocument(opaqueRoot,[{id:opaqueRoot,parent:null},{id:opaqueChild,parent:{canvasId:opaqueRoot,entryElementId:opaqueFrame.id}}],[opaqueU,opaqueV,opaqueFrame,opaqueKnown,opaqueMember,opaqueEdge,regularEdge,partitionEdge]);documents.set(opaqueRoot,opaqueOriginal);versions.set(opaqueRoot,1);versions.set(opaqueChild,1);boards.push({id:opaqueRoot,workspace_id:workspace,kind:'board',version:1,title:'Compatibility board'}); const opaqueOriginal=createDocument(opaqueRoot,[{id:opaqueRoot,parent:null},{id:opaqueChild,parent:{canvasId:opaqueRoot,entryElementId:opaqueFrame.id}}],[opaqueU,opaqueV,opaqueFrame,opaqueKnown,opaqueMember,opaqueEdge,regularEdge,partitionEdge]);documents.set(opaqueRoot,opaqueOriginal);versions.set(opaqueRoot,1);versions.set(opaqueChild,1);boards.push({id:opaqueRoot,workspace_id:workspace,kind:'board',version:1,title:'Compatibility board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${opaqueRoot}`});await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque placeholders or their original connections did not render'); await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${opaqueRoot}`});await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque placeholders or their original connections did not render');
assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node').length`),4);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="opaque-member"]')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('draggable')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('textarea,input').length`),0);assert.ok(await evaluate(`[...document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('[data-handleid]')].some(handle=>handle.dataset.handleid==='custom-out'&&!handle.classList.contains('connectablestart')&&!handle.classList.contains('connectableend'))`)); assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node').length`),4);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="opaque-member"]')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('draggable')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('textarea,input').length`),0);assert.ok(await evaluate(`[...document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('[data-handleid]')].some(handle=>handle.dataset.handleid==='custom-out'&&!handle.classList.contains('connectablestart')&&!handle.classList.contains('connectableend'))`));
// 本场景的 opaque-u 占位仍可选且有视觉反馈,但不可拖动/编辑/连线;已知邻居编辑不得改写不透明记录。
const opaqueBorder=await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`);await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('selected')`,'opaque selection did not publish');assert.notEqual(await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`),opaqueBorder,'placeholder selection was not visible'); const opaqueBorder=await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`);await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('selected')`,'opaque selection did not publish');assert.notEqual(await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`),opaqueBorder,'placeholder selection was not visible');
await evaluate(`(()=>{const card=document.querySelector('.react-flow__node[data-id="opaque-known"]');card.dispatchEvent(new MouseEvent('click',{bubbles:true}));card.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="opaque-known"] textarea')&&!document.activeElement.readOnly`,'known neighbour was not editable');await evaluate('document.activeElement.select()');await call('Input.insertText',{text:'Known edit with opaque neighbours'});await evaluate('document.activeElement.blur()');for(let i=0;i<100&&documents.get(opaqueRoot).elements.get('opaque-known').data.text!=='Known edit with opaque neighbours';i++)await pause(20);assert.equal(documents.get(opaqueRoot).elements.get('opaque-known').data.text,'Known edit with opaque neighbours');for(const record of [opaqueU,opaqueV,opaqueFrame,opaqueEdge])assert.deepEqual(documents.get(opaqueRoot).elements.get(record.id),record);assert.equal(calls.some(call=>call.path.endsWith('/assets/opaque-asset')),false); await evaluate(`(()=>{const card=document.querySelector('.react-flow__node[data-id="opaque-known"]');card.dispatchEvent(new MouseEvent('click',{bubbles:true}));card.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="opaque-known"] textarea')&&!document.activeElement.readOnly`,'known neighbour was not editable');await evaluate('document.activeElement.select()');await call('Input.insertText',{text:'Known edit with opaque neighbours'});await evaluate('document.activeElement.blur()');for(let i=0;i<100&&documents.get(opaqueRoot).elements.get('opaque-known').data.text!=='Known edit with opaque neighbours';i++)await pause(20);assert.equal(documents.get(opaqueRoot).elements.get('opaque-known').data.text,'Known edit with opaque neighbours');for(const record of [opaqueU,opaqueV,opaqueFrame,opaqueEdge])assert.deepEqual(documents.get(opaqueRoot).elements.get(record.id),record);assert.equal(calls.some(call=>call.path.endsWith('/assets/opaque-asset')),false);
// 重载后占位和连接仍在,未知 frame 的子成员不应递归展示;并核对未请求 opaque-asset 的显示字节。
await call('Page.reload');await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque content or connections did not survive recovery');assert.equal(documents.get(opaqueRoot).elements.get('opaque-partition').target.elementId,'opaque-member'); await call('Page.reload');await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque content or connections did not survive recovery');assert.equal(documents.get(opaqueRoot).elements.get('opaque-partition').target.elementId,'opaque-member');
await click('适应画布');await pause(100);await click('缩小');await pause(100);await click('缩小');await pause(100);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await click('适应画布');await pause(100);await click('缩小');await pause(100);await click('缩小');await pause(100);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await writeFile(join(tmpdir(),'headless-opaque-renderer.png'),Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64')); await writeFile(join(tmpdir(),'headless-opaque-renderer.png'),Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
console.log('PASS unknown types and future versions render safe read-only placeholders with custom/partition connections, preserve opaque payloads through edits and recovery, and never load opaque asset fields'); console.log('PASS unknown types and future versions render safe read-only placeholders with custom/partition connections, preserve opaque payloads through edits and recovery, and never load opaque asset fields');
// 宿主扩展安装三个计量节点、两条自定义边和一条内置边,检查自定义端口与原始 data/path 保留。
const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterThree={...meter,id:'meter-three',geometry:{...meter.geometry,x:1150},data:{metric:12}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}}; const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterThree={...meter,id:'meter-three',geometry:{...meter.geometry,x:1150},data:{metric:12}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}};
documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})'),projectsBefore=await evaluate('({...window.__fixtureProjectedNodes})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');const projectsAfter=await evaluate('({...window.__fixtureProjectedNodes})');assert.ok(projectsAfter['meter-one']>projectsBefore['meter-one']);assert.equal(projectsAfter['meter-two'],projectsBefore['meter-two'],'unrelated node projection rebuilt during edit/ACK');assert.equal(projectsAfter['meter-three'],projectsBefore['meter-three'],'unrelated node projection rebuilt during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK'); documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})'),projectsBefore=await evaluate('({...window.__fixtureProjectedNodes})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');const projectsAfter=await evaluate('({...window.__fixtureProjectedNodes})');assert.ok(projectsAfter['meter-one']>projectsBefore['meter-one']);assert.equal(projectsAfter['meter-two'],projectsBefore['meter-two'],'unrelated node projection rebuilt during edit/ACK');assert.equal(projectsAfter['meter-three'],projectsBefore['meter-three'],'unrelated node projection rebuilt during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK');
await until(`window.__fixtureActualEngine?.projectId===${JSON.stringify(extensionRoot)}||window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(extensionRoot)}`,'extension engine unavailable'); await until(`window.__fixtureActualEngine?.projectId===${JSON.stringify(extensionRoot)}||window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(extensionRoot)}`,'extension engine unavailable');
// 完整 scene rebuild 与逐节点 projection 分开计数;viewport/tool/mode 来回变化不应触发整图重建。
const beforeScene=await evaluate('window.__fixtureSceneBuilds');assert.ok(beforeScene>0); const beforeScene=await evaluate('window.__fixtureSceneBuilds');assert.ok(beforeScene>0);
await evaluate(`(()=>{const e=window.__fixtureActualEngine,v=[...e.sessions.getSnapshot().views.values()][0];window.__fixtureViewBefore={id:v.id,viewport:v.viewport,tool:v.tool};e.sessions.viewport(v.id,{x:80,y:45,zoom:1.2});e.sessions.tool(v.id,'pan');e.sessions.mode(v.id,'meter-one','projection-check');})()`); await evaluate(`(()=>{const e=window.__fixtureActualEngine,v=[...e.sessions.getSnapshot().views.values()][0];window.__fixtureViewBefore={id:v.id,viewport:v.viewport,tool:v.tool};e.sessions.viewport(v.id,{x:80,y:45,zoom:1.2});e.sessions.tool(v.id,'pan');e.sessions.mode(v.id,'meter-one','projection-check');})()`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');await pause(100); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');await pause(100);
@@ -616,31 +489,23 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureSceneBuilds'),beforeScene); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureSceneBuilds'),beforeScene);
console.log('PASS actual CanvasView viewport/tool/mode transitions reuse the scene without projection traversal'); console.log('PASS actual CanvasView viewport/tool/mode transitions reuse the scene without projection traversal');
// 插件按钮的 metric/raw 两次修改属于同一事务历史;undo/redo/reload 均通过公开命令和存储恢复。
await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover'); await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover');
console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload'); console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload');
// 无 rendered target 的旧 editing facade 必须无效,不能仅凭元素 ID 越过作用域校验。
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed'); assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed');
// 目标按钮先 preventDefault 的 Escape 保留手势;后续另测 shadow textarea、select 和未消费的外部 Escape。
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),button=document.createElement('button');document.querySelector('[data-headless-canvas]').append(button);button.addEventListener('keydown',event=>event.preventDefault());engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const token=engine.sessions.getSnapshot().gesture?.token;button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));const retained=!!token&&engine.sessions.getSnapshot().gesture?.token===token;button.remove();engine.sessions.cancel('fixture_cleanup');return retained;})()`),true,'capture-phase Escape canceled a gesture before the target could claim it'); assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),button=document.createElement('button');document.querySelector('[data-headless-canvas]').append(button);button.addEventListener('keydown',event=>event.preventDefault());engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const token=engine.sessions.getSnapshot().gesture?.token;button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));const retained=!!token&&engine.sessions.getSnapshot().gesture?.token===token;button.remove();engine.sessions.cancel('fixture_cleanup');return retained;})()`),true,'capture-phase Escape canceled a gesture before the target could claim it');
assert.deepEqual(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),canvas=document.querySelector('[data-headless-canvas]'),host=document.createElement('div');canvas.append(host);const shadow=host.attachShadow({mode:'open'}),textarea=document.createElement('textarea');shadow.append(textarea);const select=document.createElement('select');canvas.append(select);const fire=element=>{const event=new KeyboardEvent('keydown',{key:'h',composed:true,bubbles:true,cancelable:true});element.dispatchEvent(event);return event.defaultPrevented;};const protectedKeys=[fire(textarea),fire(select)];host.remove();select.remove();engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const outside=document.createElement('button');document.body.append(outside);const event=new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true});outside.dispatchEvent(event);outside.remove();return{protectedKeys,tool:engine.sessions.getSnapshot().views.get(view.id).tool,canceled:!engine.sessions.getSnapshot().gesture,claimed:event.defaultPrevented};})()`),{protectedKeys:[false,false],tool:'select',canceled:true,claimed:true}); assert.deepEqual(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),canvas=document.querySelector('[data-headless-canvas]'),host=document.createElement('div');canvas.append(host);const shadow=host.attachShadow({mode:'open'}),textarea=document.createElement('textarea');shadow.append(textarea);const select=document.createElement('select');canvas.append(select);const fire=element=>{const event=new KeyboardEvent('keydown',{key:'h',composed:true,bubbles:true,cancelable:true});element.dispatchEvent(event);return event.defaultPrevented;};const protectedKeys=[fire(textarea),fire(select)];host.remove();select.remove();engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const outside=document.createElement('button');document.body.append(outside);const event=new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true});outside.dispatchEvent(event);outside.remove();return{protectedKeys,tool:engine.sessions.getSnapshot().views.get(view.id).tool,canceled:!engine.sessions.getSnapshot().gesture,claimed:event.defaultPrevented};})()`),{protectedKeys:[false,false],tool:'select',canceled:true,claimed:true});
console.log('PASS keyboard targets claim Escape before canvas cancellation; native selects and shadow text retain their input, while unclaimed outside Escape cancels the owned gesture'); console.log('PASS keyboard targets claim Escape before canvas cancellation; native selects and shadow text retain their input, while unclaimed outside Escape cancels the owned gesture');
// 删除再 undo 后元素进入第二代;捕获的第一代旧 facade 必须拒绝编辑并保持 Root 引用不变。
const staleEdit=await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],target=engine.getSnapshot().document.elements.get('meter-one');window.__fixtureStaleLegacy=old;engine.editor.dispatch({type:'element.remove',target:{id:target.id,incarnation:target.incarnation}});engine.editor.history.undo();const restored=engine.getSnapshot();old.rename('meter-one','stale callback');return{unchanged:engine.getSnapshot()===restored,incarnation:restored.document.elements.get('meter-one').incarnation};})()`);assert.equal(staleEdit.incarnation,2);assert.equal(staleEdit.unchanged,true,'old rendered edit callback modified a restored incarnation'); const staleEdit=await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],target=engine.getSnapshot().document.elements.get('meter-one');window.__fixtureStaleLegacy=old;engine.editor.dispatch({type:'element.remove',target:{id:target.id,incarnation:target.incarnation}});engine.editor.history.undo();const restored=engine.getSnapshot();old.rename('meter-one','stale callback');return{unchanged:engine.getSnapshot()===restored,incarnation:restored.document.elements.get('meter-one').incarnation};})()`);assert.equal(staleEdit.incarnation,2);assert.equal(staleEdit.unchanged,true,'old rendered edit callback modified a restored incarnation');
console.log('PASS old rendered edit callbacks cannot modify a restored incarnation'); console.log('PASS old rendered edit callbacks cannot modify a restored incarnation');
// 等待 renderer 绑定新身份后新 facade 才可写;旧目标 resize 清理也不能取消另一个目标的 mode。
await until(`window.__fixtureLegacy['meter-one']!==window.__fixtureStaleLegacy&&window.__fixtureEngine.getSnapshot().document.elements.get('meter-one').incarnation===2`,'restored renderer did not bind a fresh edit target');await evaluate(`window.__fixtureLegacy['meter-one'].rename('meter-one','fresh callback')`);for(let i=0;i<200&&documents.get(extensionRoot).elements.get('meter-one')?.data.title!=='fresh callback';i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.title,'fresh callback'); await until(`window.__fixtureLegacy['meter-one']!==window.__fixtureStaleLegacy&&window.__fixtureEngine.getSnapshot().document.elements.get('meter-one').incarnation===2`,'restored renderer did not bind a fresh edit target');await evaluate(`window.__fixtureLegacy['meter-one'].rename('meter-one','fresh callback')`);for(let i=0;i<200&&documents.get(extensionRoot).elements.get('meter-one')?.data.title!=='fresh callback';i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.title,'fresh callback');
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],other=window.__fixtureLegacy['meter-two'];old.resize('meter-one');other.resize('meter-two');old.resize(null);const view=[...engine.sessions.getSnapshot().views.values()][0],retained=view.modes.get('meter-two')==='resize';other.resize(null);return retained;})()`),true,'old target mode cleanup canceled another target'); assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],other=window.__fixtureLegacy['meter-two'];old.resize('meter-one');other.resize('meter-two');old.resize(null);const view=[...engine.sessions.getSnapshot().views.values()][0],retained=view.modes.get('meter-two')==='resize';other.resize(null);return retained;})()`),true,'old target mode cleanup canceled another target');
await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension state did not settle before insertion');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionBefore=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})'),extensionSize=documents.get(extensionRoot).elements.size;await click('添加文本卡片'); await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension state did not settle before insertion');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionBefore=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})'),extensionSize=documents.get(extensionRoot).elements.size;await click('添加文本卡片');
// 新增普通文本并等 ACK 后,三个未变化计量节点 projection 计数与完整 scene 计数应都不增加。
for(let i=0;i<200&&documents.get(extensionRoot).elements.size!==extensionSize+1;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.size,extensionSize+1);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'inserted card did not synchronize');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionAfter=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})');for(const id of ['meter-one','meter-two','meter-three'])assert.equal(insertionAfter.projects[id],insertionBefore.projects[id],'ordinary card insertion rebuilt '+id);assert.equal(insertionAfter.scenes,insertionBefore.scenes,'ordinary card insertion rebuilt the whole scene');console.log('PASS actual toolbar card insertion and ACK retain every unrelated node projection and avoid full scene traversal'); for(let i=0;i<200&&documents.get(extensionRoot).elements.size!==extensionSize+1;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.size,extensionSize+1);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'inserted card did not synchronize');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionAfter=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})');for(const id of ['meter-one','meter-two','meter-three'])assert.equal(insertionAfter.projects[id],insertionBefore.projects[id],'ordinary card insertion rebuilt '+id);assert.equal(insertionAfter.scenes,insertionBefore.scenes,'ordinary card insertion rebuilt the whole scene');console.log('PASS actual toolbar card insertion and ACK retain every unrelated node projection and avoid full scene traversal');
// 导航前保存旧 editing facade;视图 detach 后项目 engine 可保留,但旧视图回调不得再写。
await evaluate(`(()=>{window.__detachedEngine=window.__fixtureEngine;window.__detachedEditing=window.__fixtureLegacy['meter-one'];[...document.querySelectorAll('a')].find(link=>link.pathname.endsWith('/${other}')).click();})()`);await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'renderer view did not detach on navigation');assert.equal(await evaluate(`(()=>{const before=window.__detachedEngine.getSnapshot();window.__detachedEditing.rename('meter-one','detached callback');return window.__detachedEngine.getSnapshot()===before;})()`),true,'detached renderer wrote into its retained project'); await evaluate(`(()=>{window.__detachedEngine=window.__fixtureEngine;window.__detachedEditing=window.__fixtureLegacy['meter-one'];[...document.querySelectorAll('a')].find(link=>link.pathname.endsWith('/${other}')).click();})()`);await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'renderer view did not detach on navigation');assert.equal(await evaluate(`(()=>{const before=window.__detachedEngine.getSnapshot();window.__detachedEditing.rename('meter-one','detached callback');return window.__detachedEngine.getSnapshot()===before;})()`),true,'detached renderer wrote into its retained project');
console.log('PASS restored renderers can edit with fresh bindings and detached views cannot write through old callbacks'); console.log('PASS restored renderers can edit with fresh bindings and detached views cannot write through old callbacks');
// 合法首元素后返回非法流尾,页面保留 partial editor 和可读错误;修复 fixture 后重新加载成为完整编辑器,全程无模型写入。
brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot; brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot;
documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'}); documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${brokenRoot}`}); await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${brokenRoot}`});
@@ -650,37 +515,29 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&document.querySelectorAll('.react-flow__node').length===2&&!document.querySelector('[role="alert"]')`,'retry did not replace the failed prefix with a complete editor'); await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&document.querySelectorAll('.react-flow__node').length===2&&!document.querySelector('[role="alert"]')`,'retry did not replace the failed prefix with a complete editor');
console.log('PASS malformed model tail retains its legal partial editor, reports a readable error and retries into the complete editor without model writes'); console.log('PASS malformed model tail retains its legal partial editor, reports a readable error and retries into the complete editor without model writes');
// 首帧包含完整根 owner,却缺子 canvas 一项;下面检查根的编辑、创建、导入、复制和图片可先 ACK,不必等子尾部。
scopeStreamRoot=id(850);const scopedRoot=scopeStreamRoot,scopedChild=id(851),scopeFrame={...node('scope-frame',scopedRoot,450),componentId:'frame',geometry:{x:450,y:150,width:600,height:450,rotation:0}}; scopeStreamRoot=id(850);const scopedRoot=scopeStreamRoot,scopedChild=id(851),scopeFrame={...node('scope-frame',scopedRoot,450),componentId:'frame',geometry:{x:450,y:150,width:600,height:450,rotation:0}};
documents.set(scopedRoot,createDocument(scopedRoot,[{id:scopedRoot,parent:null},{id:scopedChild,parent:{canvasId:scopedRoot,entryElementId:scopeFrame.id}}],[node('scope-node',scopedRoot,80),scopeFrame,node('scope-child',scopedChild,20),node('scope-tail',scopedChild,320)]));versions.set(scopedRoot,1);versions.set(scopedChild,1);boards.push({id:scopedRoot,workspace_id:workspace,kind:'board',version:1,title:'Scope ACK board'}); documents.set(scopedRoot,createDocument(scopedRoot,[{id:scopedRoot,parent:null},{id:scopedChild,parent:{canvasId:scopedRoot,entryElementId:scopeFrame.id}}],[node('scope-node',scopedRoot,80),scopeFrame,node('scope-child',scopedChild,20),node('scope-tail',scopedChild,320)]));versions.set(scopedRoot,1);versions.set(scopedChild,1);boards.push({id:scopedRoot,workspace_id:workspace,kind:'board',version:1,title:'Scope ACK board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${scopedRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="scope-node"]')`,'scope fixture did not show its certified owner'); await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${scopedRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="scope-node"]')`,'scope fixture did not show its certified owner');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await until(`(()=>{const layer=document.querySelector('.react-flow__node[data-id="scope-node"] [aria-label^="单击选中"]');if(!layer)return false;const r=layer.getBoundingClientRect();for(const x of [.25,.5,.75,.9])for(const y of [.25,.5,.75]){const point={x:r.x+r.width*x,y:r.y+r.height*y};if(layer.contains(document.elementFromPoint(point.x,point.y))){window.__scopeEditPoint=point;return true;}}return false;})()`,'scope text remained covered by another surface');const scopePoint=await evaluate('window.__scopeEditPoint'); await until(`(()=>{const layer=document.querySelector('.react-flow__node[data-id="scope-node"] [aria-label^="单击选中"]');if(!layer)return false;const r=layer.getBoundingClientRect();for(const x of [.25,.5,.75,.9])for(const y of [.25,.5,.75]){const point={x:r.x+r.width*x,y:r.y+r.height*y};if(layer.contains(document.elementFromPoint(point.x,point.y))){window.__scopeEditPoint=point;return true;}}return false;})()`,'scope text remained covered by another surface');const scopePoint=await evaluate('window.__scopeEditPoint');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'certified scope text did not enter editing');await call('Input.insertText',{text:' ACK before tail'});const scopeText=await evaluate('document.activeElement.value'); await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'certified scope text did not enter editing');await call('Input.insertText',{text:' ACK before tail'});const scopeText=await evaluate('document.activeElement.value');
// 完整根作用域先到版本 2,子作用域仍为 1 且 scope-tail 未显示,证明不是等整个项目完成才发送。
for(let i=0;i<150&&documents.get(scopedRoot).elements.get('scope-node').data.text!==scopeText;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get('scope-node').data.text,scopeText,'certified scope edit waited for another owner tail');assert.equal(versions.get(scopedRoot),2);assert.equal(versions.get(scopedChild),1);assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`),false); for(let i=0;i<150&&documents.get(scopedRoot).elements.get('scope-node').data.text!==scopeText;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get('scope-node').data.text,scopeText,'certified scope edit waited for another owner tail');assert.equal(versions.get(scopedRoot),2);assert.equal(versions.get(scopedChild),1);assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`),false);
assert.equal(await evaluate(`document.querySelector('button[aria-label="添加文本卡片"]').disabled`),false);await click('添加文本卡片'); assert.equal(await evaluate(`document.querySelector('button[aria-label="添加文本卡片"]').disabled`),false);await click('添加文本卡片');
// 来源未完整时新建卡片的 undo/redo 仍使用新 incarnation;根版本递增到 5,不能把出生与旧源清单混淆。
for(let i=0;i<150&&documents.get(scopedRoot).elements.size!==5;i++)await pause(20);const born=[...documents.get(scopedRoot).elements.values()].find(element=>!['scope-node','scope-frame','scope-child','scope-tail'].includes(element.id));assert.ok(born,'certified scope did not create a new card before the tail');await click('撤销');for(let i=0;i<150&&documents.get(scopedRoot).elements.has(born.id);i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.has(born.id),false);await click('重做');for(let i=0;i<150&&documents.get(scopedRoot).elements.get(born.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);assert.equal(versions.get(scopedRoot),5); for(let i=0;i<150&&documents.get(scopedRoot).elements.size!==5;i++)await pause(20);const born=[...documents.get(scopedRoot).elements.values()].find(element=>!['scope-node','scope-frame','scope-child','scope-tail'].includes(element.id));assert.ok(born,'certified scope did not create a new card before the tail');await click('撤销');for(let i=0;i<150&&documents.get(scopedRoot).elements.has(born.id);i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.has(born.id),false);await click('重做');for(let i=0;i<150&&documents.get(scopedRoot).elements.get(born.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);assert.equal(versions.get(scopedRoot),5);
assert.equal(await evaluate(`document.querySelector('button[aria-label="导入画布记录"]').disabled`),false); assert.equal(await evaluate(`document.querySelector('button[aria-label="导入画布记录"]').disabled`),false);
// 在认证根内从文件输入加入记录,再复制已闭合的 scope-node;每项都检查尾部到达前服务模型已增加。
await evaluate(`(()=>{const input=document.querySelector('input[data-canvas-import]'),transfer=new DataTransfer();transfer.items.add(new File([JSON.stringify({kind:'element',key:'partial-import',record:{id:'partial-import',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'Imported before tail',text:'streaming'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}})+'\\n'],'partial.ndjson',{type:'application/x-ndjson'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`); await evaluate(`(()=>{const input=document.querySelector('input[data-canvas-import]'),transfer=new DataTransfer();transfer.items.add(new File([JSON.stringify({kind:'element',key:'partial-import',record:{id:'partial-import',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'Imported before tail',text:'streaming'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}})+'\\n'],'partial.ndjson',{type:'application/x-ndjson'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
for(let i=0;i<200&&![...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail');i++)await pause(20);assert.ok([...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail'),'partial page import did not reach server before tail'); for(let i=0;i<200&&![...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail');i++)await pause(20);assert.ok([...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail'),'partial page import did not reach server before tail');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('button[aria-label="深复制选中内容"]').disabled`,'closed source copy stayed disabled during loading');const copyCount=documents.get(scopedRoot).elements.size;await click('深复制选中内容'); await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('button[aria-label="深复制选中内容"]').disabled`,'closed source copy stayed disabled during loading');const copyCount=documents.get(scopedRoot).elements.size;await click('深复制选中内容');
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===copyCount;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.size,copyCount+1,'page copy did not publish before tail');await until(`!!document.querySelector('button[aria-label="深复制选中内容"]')`,'copy did not finish');assert.equal(await evaluate(`document.querySelector('button[aria-label="添加图片"]').disabled`),false);const beforeImage=documents.get(scopedRoot).elements.size,uploadsBefore=uploads.length; for(let i=0;i<200&&documents.get(scopedRoot).elements.size===copyCount;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.size,copyCount+1,'page copy did not publish before tail');await until(`!!document.querySelector('button[aria-label="深复制选中内容"]')`,'copy did not finish');assert.equal(await evaluate(`document.querySelector('button[aria-label="添加图片"]').disabled`),false);const beforeImage=documents.get(scopedRoot).elements.size,uploadsBefore=uploads.length;
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),transfer=new DataTransfer();transfer.items.add(new File([bytes],'loading-pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),transfer=new DataTransfer();transfer.items.add(new File([bytes],'loading-pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
// 真实 PNG 在同一认证根上传并自动定高 130,loading 标志仍在;保存此时版本供尾部合并检查。
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===beforeImage;i++)await pause(20);const loadingImage=[...documents.get(scopedRoot).elements.values()].find(element=>element.componentId==='image');assert.ok(loadingImage,'scoped image waited for the unrelated tail');assert.equal(uploads.length,uploadsBefore+1);assert.equal(loadingImage.geometry.height,130,'scoped image lost default sizing');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);const scopeVersion=versions.get(scopedRoot); for(let i=0;i<200&&documents.get(scopedRoot).elements.size===beforeImage;i++)await pause(20);const loadingImage=[...documents.get(scopedRoot).elements.values()].find(element=>element.componentId==='image');assert.ok(loadingImage,'scoped image waited for the unrelated tail');assert.equal(uploads.length,uploadsBefore+1);assert.equal(loadingImage.geometry.height,130,'scoped image lost default sizing');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);const scopeVersion=versions.get(scopedRoot);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'original text focus did not return after structural history'); await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'original text focus did not return after structural history');
// 放行最初捕获尾部后保留同一 textarea/焦点/文字、第二代出生记录和已推进版本,不能重复发送旧来源。
await evaluate('window.__scopeTextarea=document.activeElement;true');releaseScopeStream();await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`,'original stream did not complete after a scoped ACK');assert.equal(await evaluate('window.__scopeTextarea.isConnected&&document.activeElement===window.__scopeTextarea'),true);assert.equal(await evaluate('window.__scopeTextarea.value'),scopeText);assert.equal(versions.get(scopedRoot),scopeVersion,'old tail caused a duplicate write');assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);console.log('PASS certified owner image/import/copy, edits and create/undo/redo ACK before another canvas tail, then original-source completion preserves content, focus, history and advanced versions'); await evaluate('window.__scopeTextarea=document.activeElement;true');releaseScopeStream();await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`,'original stream did not complete after a scoped ACK');assert.equal(await evaluate('window.__scopeTextarea.isConnected&&document.activeElement===window.__scopeTextarea'),true);assert.equal(await evaluate('window.__scopeTextarea.value'),scopeText);assert.equal(versions.get(scopedRoot),scopeVersion,'old tail caused a duplicate write');assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);console.log('PASS certified owner image/import/copy, edits and create/undo/redo ACK before another canvas tail, then original-source completion preserves content, focus, history and advanced versions');
// 另一个 partial 项目在未完整 owner 上有本地编辑;导航离开后由 project owner 采用新完整读取完成保存和同步。
abandonedRoot=id(840);const backgroundRoot=abandonedRoot; abandonedRoot=id(840);const backgroundRoot=abandonedRoot;
documents.set(backgroundRoot,createDocument(backgroundRoot,[{id:backgroundRoot,parent:null}],[node('background-node',backgroundRoot,80),node('background-tail',backgroundRoot,450)]));versions.set(backgroundRoot,1);boards.push({id:backgroundRoot,workspace_id:workspace,kind:'board',version:1,title:'Background loading board'}); documents.set(backgroundRoot,createDocument(backgroundRoot,[{id:backgroundRoot,parent:null}],[node('background-node',backgroundRoot,80),node('background-tail',backgroundRoot,450)]));versions.set(backgroundRoot,1);boards.push({id:backgroundRoot,workspace_id:workspace,kind:'board',version:1,title:'Background loading board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${backgroundRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="background-node"]')`,'background fixture did not open the prefix'); await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${backgroundRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="background-node"]')`,'background fixture did not open the prefix');
// 先原生编辑前缀并断言无 POST,再导航另一项目;检查后台取得非 NDJSON 新完整读且晚到旧流不能改当前路由。
const backgroundPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="background-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`); const backgroundPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="background-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...backgroundPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...backgroundPoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="background-node"] textarea')&&!document.activeElement.readOnly`,'background prefix did not enter editing');await call('Input.insertText',{text:' background-local'});const backgroundText=await evaluate('document.activeElement.value'); await call('Input.dispatchMouseEvent',{type:'mousePressed',...backgroundPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...backgroundPoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="background-node"] textarea')&&!document.activeElement.readOnly`,'background prefix did not enter editing');await call('Input.insertText',{text:' background-local'});const backgroundText=await evaluate('document.activeElement.value');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+backgroundRoot+'/canvas/model/operations')).length,0); assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+backgroundRoot+'/canvas/model/operations')).length,0);
@@ -688,70 +545,56 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
for(let i=0;i<150&&documents.get(backgroundRoot).elements.get('background-node').data.text!==backgroundText;i++)await pause(20);assert.equal(documents.get(backgroundRoot).elements.get('background-node').data.text,backgroundText,'leaving the view stranded a local edit behind the loading hold');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+backgroundRoot+'/canvas/model')&&call.headers.accept!=='application/vnd.foundry.canvas-model+ndjson;version=1')); for(let i=0;i<150&&documents.get(backgroundRoot).elements.get('background-node').data.text!==backgroundText;i++)await pause(20);assert.equal(documents.get(backgroundRoot).elements.get('background-node').data.text,backgroundText,'leaving the view stranded a local edit behind the loading hold');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+backgroundRoot+'/canvas/model')&&call.headers.accept!=='application/vnd.foundry.canvas-model+ndjson;version=1'));
releaseAbandoned();await pause(100);assert.ok((await evaluate('location.pathname')).endsWith(other));console.log('PASS leaving a partial editor hands completion to the project owner, persists and synchronizes its edit through a fresh read without reopening the view'); releaseAbandoned();await pause(100);assert.ok((await evaluate('location.pathname')).endsWith(other));console.log('PASS leaving a partial editor hands completion to the project owner, persists and synchronizes its edit through a fresh read without reopening the view');
// JSON 数组通过真实 file input 进入原生记录导入,既核对页面和服务内容,也在 reload 后检查来源文件名与作业。
await evaluate(`(()=>{const transfer=new DataTransfer(),record={kind:'element',key:'json-ui',record:{id:'json-ui',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'JSON array UI',text:'JSON array UI text'},geometry:{x:80,y:100,width:240,height:160,rotation:0}}};transfer.items.add(new File([JSON.stringify([record])],'records.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(()=>{const transfer=new DataTransfer(),record={kind:'element',key:'json-ui',record:{id:'json-ui',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'JSON array UI',text:'JSON array UI text'},geometry:{x:80,y:100,width:240,height:160,rotation:0}}};transfer.items.add(new File([JSON.stringify([record])],'records.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelector('[aria-label="画布导入"]').textContent.includes('已加入画布')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')`,'JSON array did not render through the file picker'); await until(`document.querySelector('[aria-label="画布导入"]').textContent.includes('已加入画布')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')`,'JSON array did not render through the file picker');
for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI');i++)await pause(20);assert.ok([...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI')); for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI');i++)await pause(20);assert.ok([...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI'));
await call('Page.reload');await until(`[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')&&document.querySelector('[aria-label="画布导入"]').textContent.includes('records.json')`,'JSON array task/content did not recover after reload'); await call('Page.reload');await until(`[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')&&document.querySelector('[aria-label="画布导入"]').textContent.includes('records.json')`,'JSON array task/content did not recover after reload');
console.log('PASS JSON array file picker imports and synchronizes native records then recovers its original task and content after reload'); console.log('PASS JSON array file picker imports and synchronizes native records then recovers its original task and content after reload');
// 临时拦截带 download 的 anchor,只读取 Blob 导出文本;随即恢复原 click,不真的发起下载。
await evaluate(`(()=>{const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(this.download){window.__exportedDocument=fetch(this.href).then(response=>response.text());return;}return original.call(this);};document.querySelector('[aria-label="导出图谱"]').click();HTMLAnchorElement.prototype.click=original;})()`); await evaluate(`(()=>{const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(this.download){window.__exportedDocument=fetch(this.href).then(response=>response.text());return;}return original.call(this);};document.querySelector('[aria-label="导出图谱"]').click();HTMLAnchorElement.prototype.click=original;})()`);
// 完整文档导出格式为 1,重导入应增加与源元素数相同的新元素;parserVersion=5 核对使用流式文档解释器。
const exported=await evaluate('window.__exportedDocument');assert.equal(JSON.parse(exported).documentFormatVersion,1);const beforeDocumentImport=documents.get(other).elements.size,roundTripElementCount=JSON.parse(exported).elements.length; const exported=await evaluate('window.__exportedDocument');assert.equal(JSON.parse(exported).documentFormatVersion,1);const beforeDocumentImport=documents.get(other).elements.size,roundTripElementCount=JSON.parse(exported).elements.length;
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(exported)}],'round-trip.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(exported)}],'round-trip.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'exported document did not reimport'); await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'exported document did not reimport');
for(let i=0;i<200&&documents.get(other).elements.size!==beforeDocumentImport+roundTripElementCount;i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeDocumentImport+roundTripElementCount);await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'document round trip task/content did not recover'); for(let i=0;i<200&&documents.get(other).elements.size!==beforeDocumentImport+roundTripElementCount;i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeDocumentImport+roundTripElementCount);await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'document round trip task/content did not recover');
assert.equal(await evaluate(`(()=>{const job=[...window.__fixtureActualEngine.imports.getJobs().values()].find(job=>job.source?.name==='round-trip.json');return job?.parserVersion;})()`),5,'document picker did not recover the streaming interpreter'); assert.equal(await evaluate(`(()=>{const job=[...window.__fixtureActualEngine.imports.getJobs().values()].find(job=>job.source?.name==='round-trip.json');return job?.parserVersion;})()`),5,'document picker did not recover the streaming interpreter');
console.log('PASS actual toolbar JSON export reimports through file picker with fresh elements and recovers after reload'); console.log('PASS actual toolbar JSON export reimports through file picker with fresh elements and recovers after reload');
// 文档图片故意带现有 assetId 与不可信旧 src,导入必须重新授权并重建显示引用,不重复上传。
const importedAssetId=[...assets.keys()][0],uploadsBeforeDocumentImage=uploads.length,imageImportDocument={documentFormatVersion:1,projectId:'image-source',canvases:[{id:'image-source',parent:null}],elements:[{id:'image-from-file',incarnation:1,ownerCanvasId:'image-source',componentId:'image',dataVersion:1,structure:'node',geometry:{x:400,y:100,width:200,height:100,rotation:0},data:{title:'Document imported image',text:'',assetId:importedAssetId,src:'https://untrusted.test/stale'}}],tombstones:{}}; const importedAssetId=[...assets.keys()][0],uploadsBeforeDocumentImage=uploads.length,imageImportDocument={documentFormatVersion:1,projectId:'image-source',canvases:[{id:'image-source',parent:null}],elements:[{id:'image-from-file',incarnation:1,ownerCanvasId:'image-source',componentId:'image',dataVersion:1,structure:'node',geometry:{x:400,y:100,width:200,height:100,rotation:0},data:{title:'Document imported image',text:'',assetId:importedAssetId,src:'https://untrusted.test/stale'}}],tombstones:{}};
const beforeImageImport=documents.get(other).elements.size; const beforeImageImport=documents.get(other).elements.size;
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(JSON.stringify(imageImportDocument))}],'image-document.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(JSON.stringify(imageImportDocument))}],'image-document.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='Document imported image');i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeImageImport+1);const importedImage=[...documents.get(other).elements.values()].find(e=>e.data.title==='Document imported image');assert.equal(importedImage.data.assetId,importedAssetId);assert.notEqual(importedImage.data.src,'https://untrusted.test/stale');assert.equal(uploads.length,uploadsBeforeDocumentImage); for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='Document imported image');i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeImageImport+1);const importedImage=[...documents.get(other).elements.values()].find(e=>e.data.title==='Document imported image');assert.equal(importedImage.data.assetId,importedAssetId);assert.notEqual(importedImage.data.src,'https://untrusted.test/stale');assert.equal(uploads.length,uploadsBeforeDocumentImage);
await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('image-document.json')&&!!document.querySelector('.react-flow__node[data-id="${importedImage.id}"] img')`,'authorized document image did not recover after reload');assert.equal(uploads.length,uploadsBeforeDocumentImage); await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('image-document.json')&&!!document.querySelector('.react-flow__node[data-id="${importedImage.id}"] img')`,'authorized document image did not recover after reload');assert.equal(uploads.length,uploadsBeforeDocumentImage);
console.log('PASS document image import reauthorizes existing resource, ignores stale URL and recovers without reuploading'); console.log('PASS document image import reauthorizes existing resource, ignores stale URL and recovers without reuploading');
// 分别控制 parser I/O 与全源摘要:先挂住摘要 Worker 结果,让解析完成和来源验证可以独立观察。
await evaluate(`(()=>{window.__fixtureDigestWaiting=false;window.__fixtureDigestGate=new Promise(resolve=>{window.__releaseSourceDigest=()=>{delete window.__fixtureDigestGate;resolve();};});})()`); await evaluate(`(()=>{window.__fixtureDigestWaiting=false;window.__fixtureDigestGate=new Promise(resolve=>{window.__releaseSourceDigest=()=>{delete window.__fixtureDigestGate;resolve();};});})()`);
// GatedDocument.slice 只延迟 16KiB 解析读取且偏移至少 32KiB 的尾部;首项可先入画布,摘要由另一 gate 控制。
await evaluate(`(()=>{const engine=window.__fixtureActualEngine,view=[...engine.sessions.getSnapshot().views.values()][0];let release;const gate=new Promise(resolve=>release=resolve);window.__releaseDocumentTail=()=>{window.__documentTailReleased=true;release();};class GatedDocument extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||window.__documentTailReleased)return part;return{async arrayBuffer(){window.__documentTailWaiting=true;await gate;return part.arrayBuffer();}};}}const element=(id,text)=>({id,incarnation:1,structure:'node',ownerCanvasId:'stream-source',componentId:'text',dataVersion:1,data:{title:id,text},geometry:{x:0,y:0,width:260,height:200,rotation:0}}),input={documentFormatVersion:1,projectId:'stream-source',canvases:[{id:'stream-source',parent:null}],elements:[element('dom-prefix','Document progressive prefix'),element('dom-tail','x'.repeat(100000))],tombstones:{}};window.__streamDocumentJob=engine.imports.startDocument(new GatedDocument([JSON.stringify(input)]),view.canvasId);})()`); await evaluate(`(()=>{const engine=window.__fixtureActualEngine,view=[...engine.sessions.getSnapshot().views.values()][0];let release;const gate=new Promise(resolve=>release=resolve);window.__releaseDocumentTail=()=>{window.__documentTailReleased=true;release();};class GatedDocument extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||window.__documentTailReleased)return part;return{async arrayBuffer(){window.__documentTailWaiting=true;await gate;return part.arrayBuffer();}};}}const element=(id,text)=>({id,incarnation:1,structure:'node',ownerCanvasId:'stream-source',componentId:'text',dataVersion:1,data:{title:id,text},geometry:{x:0,y:0,width:260,height:200,rotation:0}}),input={documentFormatVersion:1,projectId:'stream-source',canvases:[{id:'stream-source',parent:null}],elements:[element('dom-prefix','Document progressive prefix'),element('dom-tail','x'.repeat(100000))],tombstones:{}};window.__streamDocumentJob=engine.imports.startDocument(new GatedDocument([JSON.stringify(input)]),view.canvasId);})()`);
// 确认 parser 尾部实际等待且未放开时,第一项 textarea 已挂载;随后原生输入必须能修改该前缀。
await until(`(()=>{const job=window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob),id=job.stableIds['element:dom-prefix'];window.__streamDocumentFirst=id;return window.__documentTailWaiting&&!window.__documentTailReleased&&!!document.querySelector('.react-flow__node[data-id="'+id+'"] textarea');})()`,'document prefix did not display while parser tail was blocked'); await until(`(()=>{const job=window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob),id=job.stableIds['element:dom-prefix'];window.__streamDocumentFirst=id;return window.__documentTailWaiting&&!window.__documentTailReleased&&!!document.querySelector('.react-flow__node[data-id="'+id+'"] textarea');})()`,'document prefix did not display while parser tail was blocked');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id="'+window.__streamDocumentFirst+'"]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="'+window.__streamDocumentFirst+'"] textarea')&&!document.activeElement.readOnly`,'progressive document prefix did not accept editing');await call('Input.insertText',{text:' edited before tail'});assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get(window.__streamDocumentFirst).data.text.endsWith('edited before tail')`),true);assert.equal(await evaluate('window.__documentTailReleased===true'),false);await evaluate('window.__releaseDocumentTail()'); await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id="'+window.__streamDocumentFirst+'"]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="'+window.__streamDocumentFirst+'"] textarea')&&!document.activeElement.readOnly`,'progressive document prefix did not accept editing');await call('Input.insertText',{text:' edited before tail'});assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get(window.__streamDocumentFirst).data.text.endsWith('edited before tail')`),true);assert.equal(await evaluate('window.__documentTailReleased===true'),false);await evaluate('window.__releaseDocumentTail()');
// parser completed 时 sha256 仍缺失且服务还无首项;释放摘要后才同步,并保留摘要/尾部等待期间的用户编辑。
await until(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).status==='completed'&&window.__fixtureDigestWaiting`,'streaming document did not complete parsing while full SHA was blocked');const streamFirstId=await evaluate('window.__streamDocumentFirst');assert.equal(await evaluate(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).source.sha256`),undefined);assert.equal(documents.get(other).elements.has(streamFirstId),false,'unverified source reached backend');await evaluate('window.__releaseSourceDigest()');for(let i=0;i<200&&!documents.get(other).elements.get(streamFirstId)?.data.text.endsWith('edited before tail');i++)await pause(20);assert.ok(documents.get(other).elements.get(streamFirstId).data.text.endsWith('edited before tail'));assert.equal(await evaluate(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).committedSourceKeys.length`),2); await until(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).status==='completed'&&window.__fixtureDigestWaiting`,'streaming document did not complete parsing while full SHA was blocked');const streamFirstId=await evaluate('window.__streamDocumentFirst');assert.equal(await evaluate(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).source.sha256`),undefined);assert.equal(documents.get(other).elements.has(streamFirstId),false,'unverified source reached backend');await evaluate('window.__releaseSourceDigest()');for(let i=0;i<200&&!documents.get(other).elements.get(streamFirstId)?.data.text.endsWith('edited before tail');i++)await pause(20);assert.ok(documents.get(other).elements.get(streamFirstId).data.text.endsWith('edited before tail'));assert.equal(await evaluate(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).committedSourceKeys.length`),2);
console.log('PASS actual document prefix displays and accepts native text edits before full SHA; parser completion cannot sync until verification, then preserves those edits'); console.log('PASS actual document prefix displays and accepts native text edits before full SHA; parser completion cannot sync until verification, then preserves those edits');
// 两端节点矩形在视口外,但自定义曲线弯入视口;不能只用端点矩形决定把可见边和必要 owner 裁掉。
const bowRoot=id(75);versions.set(bowRoot,1);boards.push({id:bowRoot,workspace_id:workspace,kind:'board',version:1,title:'Bowed paint board'});documents.set(bowRoot,createDocument(bowRoot,[{id:bowRoot,parent:null}],[{...node('bow-source',bowRoot,0),geometry:{x:0,y:2000,width:260,height:200,rotation:0}},{...node('bow-target',bowRoot,600),geometry:{x:600,y:2000,width:260,height:200,rotation:0}},{id:'bow-edge',incarnation:1,ownerCanvasId:bowRoot,structure:'connection',componentId:'connection',dataVersion:1,data:{},source:{elementId:'bow-source'},target:{elementId:'bow-target'},path:{type:'bezier-chain',knots:[{id:'bow-knot',mode:'corner',position:{x:400,y:0},in:{x:-100,y:0},out:{x:100,y:0}}]}}])); const bowRoot=id(75);versions.set(bowRoot,1);boards.push({id:bowRoot,workspace_id:workspace,kind:'board',version:1,title:'Bowed paint board'});documents.set(bowRoot,createDocument(bowRoot,[{id:bowRoot,parent:null}],[{...node('bow-source',bowRoot,0),geometry:{x:0,y:2000,width:260,height:200,rotation:0}},{...node('bow-target',bowRoot,600),geometry:{x:600,y:2000,width:260,height:200,rotation:0}},{id:'bow-edge',incarnation:1,ownerCanvasId:bowRoot,structure:'connection',componentId:'connection',dataVersion:1,data:{},source:{elementId:'bow-source'},target:{elementId:'bow-target'},path:{type:'bezier-chain',knots:[{id:'bow-knot',mode:'corner',position:{x:400,y:0},in:{x:-100,y:0},out:{x:100,y:0}}]}}]));
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${bowRoot}`});await until(`!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path')`,'bowed curve fixture did not open'); await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${bowRoot}`});await until(`!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path')`,'bowed curve fixture did not open');
await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),r=pane.getBoundingClientRect(),m=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(400*m.a+m.e-r.width/2)/.5,deltaY:(m.f-r.height/2)/.5,bubbles:true,cancelable:true}));})()`); await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),r=pane.getBoundingClientRect(),m=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(400*m.a+m.e-r.width/2)/.5,deltaY:(m.f-r.height/2)/.5,bubbles:true,cancelable:true}));})()`);
await until(`(()=>{const viewport=document.querySelector('.react-flow__pane').getBoundingClientRect(),nodes=[...document.querySelectorAll('.react-flow__node')];return nodes.length===2&&nodes.every(node=>{const r=node.getBoundingClientRect();return r.bottom<viewport.top||r.top>viewport.bottom;})&&!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path');})()`,'offscreen endpoints caused visible bowed paint to unmount'); await until(`(()=>{const viewport=document.querySelector('.react-flow__pane').getBoundingClientRect(),nodes=[...document.querySelectorAll('.react-flow__node')];return nodes.length===2&&nodes.every(node=>{const r=node.getBoundingClientRect();return r.bottom<viewport.top||r.top>viewport.bottom;})&&!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path');})()`,'offscreen endpoints caused visible bowed paint to unmount');
assert.equal(documents.get(bowRoot).elements.size,3);console.log('PASS bowed curve paint remains mounted with both endpoint rectangles outside the viewport'); assert.equal(documents.get(bowRoot).elements.size,3);console.log('PASS bowed curve paint remains mounted with both endpoint rectangles outside the viewport');
// 嵌套节点的拖动预览绑定起点坐标上下文;外部移动其 ancestor 时必须取消该预览。
const contextRoot=id(852),contextChild=id(853),contextFrame={...node('context-frame',contextRoot,400),componentId:'frame',geometry:{x:400,y:100,width:640,height:400,rotation:0}},contextNode={...node('context-node',contextChild,50),geometry:{x:50,y:60,width:260,height:200,rotation:0}}; const contextRoot=id(852),contextChild=id(853),contextFrame={...node('context-frame',contextRoot,400),componentId:'frame',geometry:{x:400,y:100,width:640,height:400,rotation:0}},contextNode={...node('context-node',contextChild,50),geometry:{x:50,y:60,width:260,height:200,rotation:0}};
documents.set(contextRoot,createDocument(contextRoot,[{id:contextRoot,parent:null},{id:contextChild,parent:{canvasId:contextRoot,entryElementId:contextFrame.id}}],[contextFrame,contextNode]));versions.set(contextRoot,1);versions.set(contextChild,1);boards.push({id:contextRoot,workspace_id:workspace,kind:'board',version:1,title:'Gesture context board'}); documents.set(contextRoot,createDocument(contextRoot,[{id:contextRoot,parent:null},{id:contextChild,parent:{canvasId:contextRoot,entryElementId:contextFrame.id}}],[contextFrame,contextNode]));versions.set(contextRoot,1);versions.set(contextChild,1);boards.push({id:contextRoot,workspace_id:workspace,kind:'board',version:1,title:'Gesture context board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${contextRoot}`});await until(`!!document.querySelector('.react-flow__node[data-id="context-node"]')&&[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'nested gesture context board did not settle'); await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${contextRoot}`});await until(`!!document.querySelector('.react-flow__node[data-id="context-node"]')&&[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'nested gesture context board did not settle');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 先 elementFromPoint 证实子节点可点,原生按下/移动后等待实际手势 token,再通过公开命令移动父 frame。
const contextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="context-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.3,y:r.y+Math.min(20,r.height*.2)}})()`); const contextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="context-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.3,y:r.y+Math.min(20,r.height*.2)}})()`);
assert.equal(await evaluate(`document.elementFromPoint(${contextPoint.x},${contextPoint.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'context-node','nested test pointer is covered by another surface'); assert.equal(await evaluate(`document.elementFromPoint(${contextPoint.x},${contextPoint.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'context-node','nested test pointer is covered by another surface');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...contextPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:1}); await call('Input.dispatchMouseEvent',{type:'mousePressed',...contextPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:1});
await until(`window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(contextRoot)}&&window.__fixtureActualEngine.sessions.getSnapshot().gesture?.targets.some(target=>target.id==='context-node')`,'nested node did not acquire its drag'); await until(`window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(contextRoot)}&&window.__fixtureActualEngine.sessions.getSnapshot().gesture?.targets.some(target=>target.id==='context-node')`,'nested node did not acquire its drag');
assert.equal(await evaluate(`window.__fixtureActualEngine.editor.dispatch({type:'element.geometry',target:{id:'context-frame',incarnation:1},patch:{x:600}}).status`),'applied'); assert.equal(await evaluate(`window.__fixtureActualEngine.editor.dispatch({type:'element.geometry',target:{id:'context-frame',incarnation:1},patch:{x:600}}).status`),'applied');
// 父变换发布后预览应清空;迟到 mouseReleased 不得写子局部几何,页面改按新父位置投影。
assert.equal(await evaluate('window.__fixtureActualEngine.sessions.getSnapshot().gesture'),null,'ancestor edit retained the old coordinate preview'); assert.equal(await evaluate('window.__fixtureActualEngine.sessions.getSnapshot().gesture'),null,'ancestor edit retained the old coordinate preview');
await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:0}); await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:0});
for(let i=0;i<100&&documents.get(contextRoot).elements.get('context-frame').geometry.x!==600;i++)await pause(20); for(let i=0;i<100&&documents.get(contextRoot).elements.get('context-frame').geometry.x!==600;i++)await pause(20);
assert.equal(documents.get(contextRoot).elements.get('context-frame').geometry.x,600);assert.deepEqual(documents.get(contextRoot).elements.get('context-node').geometry,contextNode.geometry); assert.equal(documents.get(contextRoot).elements.get('context-frame').geometry.x,600);assert.deepEqual(documents.get(contextRoot).elements.get('context-node').geometry,contextNode.geometry);
await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="context-node"]');return node&&Math.abs(new DOMMatrix(node.style.transform).m41-674)<.01&&!node.classList.contains('dragging');})()`,'nested node did not return to its new ancestor projection'); await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="context-node"]');return node&&Math.abs(new DOMMatrix(node.style.transform).m41-674)<.01&&!node.classList.contains('dragging');})()`,'nested node did not return to its new ancestor projection');
// 检查服务收到的 changes 只涉及父移动,不含 context-node,补足单看 DOM 复位无法证明的“未写旧坐标”。
const contextWrites=calls.filter(call=>call.method==='POST'&&call.path.includes('/'+contextRoot+'/canvas/model/operations')).flatMap(call=>JSON.parse(call.bytes.toString()).operations.flatMap(operation=>operation.changes));assert.ok(contextWrites.length);assert.equal(contextWrites.some(change=>change.target?.id==='context-node'),false,'late nested drag wrote the old coordinate result'); const contextWrites=calls.filter(call=>call.method==='POST'&&call.path.includes('/'+contextRoot+'/canvas/model/operations')).flatMap(call=>JSON.parse(call.bytes.toString()).operations.flatMap(operation=>operation.changes));assert.ok(contextWrites.length);assert.equal(contextWrites.some(change=>change.target?.id==='context-node'),false,'late nested drag wrote the old coordinate result');
console.log('PASS ancestor movement cancels the actual nested drag; late pointerup preserves child geometry and renders its new parent position'); console.log('PASS ancestor movement cancels the actual nested drag; late pointerup preserves child geometry and renders its new parent position');
// 最后走实际账户菜单退出登录,要求安全到 /login 且无 render error;另核对至少有一次真实代理 NDJSON 协商。
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`); await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view'); await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1')); assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'));
@@ -759,13 +602,9 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
console.log('PASS logout retires the runtime before dropping its view without a render error'); console.log('PASS logout retires the runtime before dropping its view without a render error');
} }
} finally { } finally {
// 统一放开挂起响应并关闭 CDP;不依赖正在等待 gate 的请求自行退出。
releaseScopeStream();releaseAbandoned();releaseFirstModel();releaseImport();releaseApply();releaseUpload(); socket?.close(); releaseScopeStream();releaseAbandoned();releaseFirstModel();releaseImport();releaseApply();releaseUpload(); socket?.close();
// 先请求浏览器退出,三秒仍未结束则 SIGKILL,再等待 exit 并销毁输出流。
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await Promise.race([exit,pause(3000)]);if(chrome.exitCode===null)chrome.kill('SIGKILL');await exit;chrome.stdin?.destroy();chrome.stdout?.destroy();chrome.stderr?.destroy(); } if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await Promise.race([exit,pause(3000)]);if(chrome.exitCode===null)chrome.kill('SIGKILL');await exit;chrome.stdin?.destroy();chrome.stdout?.destroy();chrome.stderr?.destroy(); }
// 按进程组向 Next 发 SIGTERM,超时用 SIGKILL,避免只杀父进程留下编译子进程。
if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} } if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} }
// 强制关闭上游残留连接再停止监听,最后删除本次应用和 profile 两个确切目录。
upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)); upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));
await rm(directory, { recursive: true, force: true, maxRetries: 5 }); await rm(profile, { recursive: true, force: true, maxRetries: 5 }); await rm(directory, { recursive: true, force: true, maxRetries: 5 }); await rm(profile, { recursive: true, force: true, maxRetries: 5 });
} }
-5
View File
@@ -53,11 +53,6 @@ test('compiler freezes protocol 2 conditional changes and transports the exact o
const calls=[],{port,request,compiled}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}}}); const calls=[],{port,request,compiled}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}}});
assert.equal(compiled.protocolVersion,2);assert.equal(request.protocolVersion,2); assert.equal(compiled.protocolVersion,2);assert.equal(request.protocolVersion,2);
const body=JSON.parse(request.body);assert.deepEqual(body.operations,[{id:operationId,changes:intent.changes}]);assert.equal(Object.hasOwn(body,'document'),false); const body=JSON.parse(request.body);assert.deepEqual(body.operations,[{id:operationId,changes:intent.changes}]);assert.equal(Object.hasOwn(body,'document'),false);
// 严格 JSON 夹具的旁注:protocol_version=2 是请求协议,不是组件 dataVersion 或文档格式版本。
// request_id 使用本测试冻结请求 ID;expected_versions 只含参与根 canvas 的已确认版本 3。
// operations[0].id 是逻辑操作 ID;target.incarnation=1 防止相同元素 ID 的新生命期接收旧修改。
// changes 是 data.title 从 a 到 renamed 的条件修改;beforePresent/afterPresent 显式区分缺失字段和有值字段。
// 深比较覆盖整个编译请求形状;这个静态样例本身不证明真实后端已接受或持久化请求。
assert.deepEqual(body,JSON.parse(await readFile(new URL('./fixtures/foundry-model-operation-v2.json',import.meta.url),'utf8'))); assert.deepEqual(body,JSON.parse(await readFile(new URL('./fixtures/foundry-model-operation-v2.json',import.meta.url),'utf8')));
const originalBytes=' \n'+request.body+' ',frozen={...request,body:originalBytes}; const originalBytes=' \n'+request.body+' ',frozen={...request,body:originalBytes};
assert.equal((await port.submit(frozen,new AbortController().signal)).outcome,'applied'); assert.equal((await port.submit(frozen,new AbortController().signal)).outcome,'applied');
-6
View File
@@ -1,12 +1,7 @@
/**
* 会话 cookie 的期限与属性单元测试。直接调用格式化函数,不连接浏览器 cookie jar 或真实认证后端。
* 后端剩余期限与前端 30 天上限取有效结果;数值 0 表示立即过期,非法输入回退默认值。
*/
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { sessionCookie, sessionMaxAge, SESSION_IDLE_SECONDS } from "../src/lib/session-cookie.ts"; import { sessionCookie, sessionMaxAge, SESSION_IDLE_SECONDS } from "../src/lib/session-cookie.ts";
// 核对秒数常量、可解析整数字符串与最大期限;0 不可误回退,负数、非有限值、非整数等使用默认 30 天。
test("session cookies respect backend remaining lifetime and a 30-day maximum", () => { test("session cookies respect backend remaining lifetime and a 30-day maximum", () => {
assert.equal(SESSION_IDLE_SECONDS, 2592000); assert.equal(SESSION_IDLE_SECONDS, 2592000);
assert.equal(sessionMaxAge(2592000), 2592000); assert.equal(sessionMaxAge(2592000), 2592000);
@@ -17,7 +12,6 @@ test("session cookies respect backend remaining lifetime and a 30-day maximum",
for (const invalid of [Infinity, "garbage", "1.5", NaN, {}, null]) assert.equal(sessionMaxAge(invalid), 2592000); for (const invalid of [Infinity, "garbage", "1.5", NaN, {}, null]) assert.equal(sessionMaxAge(invalid), 2592000);
}); });
// 只检查生成的 Set-Cookie 文本:后端期限、HttpOnly、SameSite=Lax、可选 Secure 和 Max-Age=0;不代表端到端会话续期已验证。
test("renewed cookies retain HttpOnly/SameSite and use the backend-capped deadline", () => { test("renewed cookies retain HttpOnly/SameSite and use the backend-capped deadline", () => {
const cookie = sessionCookie("session-token", 42, true); const cookie = sessionCookie("session-token", 42, true);
assert.match(cookie, /Max-Age=42;/); assert.match(cookie, /HttpOnly/); assert.match(cookie, /SameSite=Lax/); assert.match(cookie, /; Secure$/); assert.match(cookie, /Max-Age=42;/); assert.match(cookie, /HttpOnly/); assert.match(cookie, /SameSite=Lax/); assert.match(cookie, /; Secure$/);
-13
View File
@@ -1,12 +1,7 @@
/**
* workspace 首页、board 创建/重命名与 URL 的请求契约单元测试。request 替身记录调用次序和 payload,不运行 HTTP 或数据库。
* 首次创建 workspace 与 board 不是一个服务端事务:workspace 成功后需先通知调用者保留身份,即使后续 board 失败。
*/
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { boardHref, createBoard, renameBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome } from "../src/lib/workspace.ts"; import { boardHref, createBoard, renameBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome } from "../src/lib/workspace.ts";
// 把两个请求都挂起后检查启动记录,证明 profile/workspaces 无串行瀑布;分别释放再检查结果整合和头像。
test("profile and workspace reads both start before either completes", async () => { test("profile and workspace reads both start before either completes", async () => {
const calls = []; const calls = [];
const resolve = {}; const resolve = {};
@@ -25,7 +20,6 @@ test("profile and workspace reads both start before either completes", async ()
assert.equal(result.workspaces[0].id, "space"); assert.equal(result.workspaces[0].id, "space");
}); });
// 没有 workspace 时先创建个人空间并回调选中,再提交 board;标题去首尾空白,不能在通知调用者前继续第二步。
test("a first board creates its personal workspace before posting the board", async () => { test("a first board creates its personal workspace before posting the board", async () => {
const calls = []; const calls = [];
let selected = ""; let selected = "";
@@ -50,7 +44,6 @@ test("a first board creates its personal workspace before posting the board", as
assert.equal(calls[1][1].title, "周末的灵感"); assert.equal(calls[1][1].title, "周末的灵感");
}); });
// 第一次 board 失败仍保留回调给出的 workspace ID;重试只创建 board,个人空间计数仍为一。
test("a board failure retains the new workspace, so retry creates no duplicate space", async () => { test("a board failure retains the new workspace, so retry creates no duplicate space", async () => {
let selected = ""; let selected = "";
let spaces = 0; let spaces = 0;
@@ -75,7 +68,6 @@ test("a board failure retains the new workspace, so retry creates no duplicate s
assert.equal(spaces, 1); assert.equal(spaces, 1);
}); });
// 已有 workspace 直接向其 materials 提交,创建空间回调不得执行。
test("an existing workspace posts a board directly", async () => { test("an existing workspace posts a board directly", async () => {
const calls = []; const calls = [];
await createBoard( await createBoard(
@@ -91,7 +83,6 @@ test("an existing workspace posts a board directly", async () => {
}); });
// 空白名称回退默认图谱标题,payload 明确 kind/content schema;返回身份采用服务分配 ID,不能另造本地替代 ID。
test("new graphs use a default name and retain the Foundry-assigned identity", async () => { test("new graphs use a default name and retain the Foundry-assigned identity", async () => {
const created = await createBoard(async (path, options) => { const created = await createBoard(async (path, options) => {
assert.equal(path, "workspaces/space/materials"); assert.equal(path, "workspaces/space/materials");
@@ -106,14 +97,12 @@ test("new graphs use a default name and retain the Foundry-assigned identity", a
assert.equal(boardHref(created.workspace_id, created.id), "/dashboard/space/server-generated-id"); assert.equal(boardHref(created.workspace_id, created.id), "/dashboard/space/server-generated-id");
}); });
// workspace 和 board 分段编码,特殊斜线/问号不能改变路由结构;demo 查询参数单独追加。
test("graph URLs preserve workspace identity and safely encode route segments", () => { test("graph URLs preserve workspace identity and safely encode route segments", () => {
assert.equal(boardHref("space/a", "id?b"), "/dashboard/space%2Fa/id%3Fb"); assert.equal(boardHref("space/a", "id?b"), "/dashboard/space%2Fa/id%3Fb");
assert.equal(boardHref("demo", "board", true), "/dashboard/demo/board?demo=1"); assert.equal(boardHref("demo", "board", true), "/dashboard/demo/board?demo=1");
}); });
// 重命名前读取最新版本,以 If-Match=7 PATCH,保留 description/content;响应为空时 helper 合成标题及 version=8 返回值,非服务端实际版本证明。
test("renaming reads the latest version and preserves description and content", async () => { test("renaming reads the latest version and preserves description and content", async () => {
const calls = []; const calls = [];
const content = { schema_version: 1, preserved: { key: "value" } }; const content = { schema_version: 1, preserved: { key: "value" } };
@@ -130,14 +119,12 @@ test("renaming reads the latest version and preserves description and content",
assert.equal(renamed.version, 8); assert.equal(renamed.version, 8);
}); });
// 空白或超过 100 字符的标题在请求前拒绝;用 assert.fail 确保没有任何 API 调用。
test("rename rejects empty or oversized names without any API calls", async () => { test("rename rejects empty or oversized names without any API calls", async () => {
for (const name of [" ", "字".repeat(101)]) { for (const name of [" ", "字".repeat(101)]) {
await assert.rejects(renameBoard(() => assert.fail("unexpected API call"), "space", "board", name), /invalid_title/); await assert.rejects(renameBoard(() => assert.fail("unexpected API call"), "space", "board", name), /invalid_title/);
} }
}); });
// PATCH 版本冲突原样抛出且只调用一次,不能自动重试覆盖新内容。
test("rename surfaces a version conflict without retrying or overwriting", async () => { test("rename surfaces a version conflict without retrying or overwriting", async () => {
let patches = 0; let patches = 0;
await assert.rejects(renameBoard(async (_path, options) => { await assert.rejects(renameBoard(async (_path, options) => {