feat: add product prototype skill and studio
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
# product-prototype
|
||||||
|
|
||||||
|
可复用的离线 Product Prototype Studio Skill:用文件型需求、动线、布局和评审数据驱动 React +
|
||||||
|
TypeScript + React Flow 原型。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch add product-prototype # 当前项目
|
||||||
|
pouch add product-prototype -g # 全局
|
||||||
|
```
|
||||||
|
|
||||||
|
Skill 安装后,从 Skill 目录运行初始化脚本。使用仓库 checkout 时命令如下:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/product-prototype/scripts/init.py ./prototype-studio
|
||||||
|
```
|
||||||
|
|
||||||
|
全局安装后可使用 `~/.pouch/skills/product-prototype/scripts/init.py`。初始化脚本是安全的
|
||||||
|
补齐操作:目标中已有文件会被保留,并在输出中标记为 `skip`。
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
- Node.js 20+ 与 npm 10+(模板声明 npm,不依赖 pnpm/yarn)。
|
||||||
|
- 一份产品目标、核心场景、页面/状态和关键交互说明。
|
||||||
|
- 运行时不需要后端、账号、真实设备或网络资源;依赖安装由目标项目自行决定。
|
||||||
|
|
||||||
|
模板是可运行的离线 Studio,不是只读的文件占位:它包含 React Flow 画布、共享组件/Action
|
||||||
|
registry、flow 驱动的 NavigationRuntime、layout 保存/恢复、review 批注导出和确定性设备管理
|
||||||
|
mock。四类源数据仍是唯一事实来源;浏览器产物和构建目录不回写源数据。
|
||||||
|
|
||||||
|
## 初始化后的目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
prototype-studio/
|
||||||
|
├── prototype/ # 源数据:requirements/flow/layout/review
|
||||||
|
├── src/
|
||||||
|
│ ├── App.tsx # React Flow Studio 与 runtime preview
|
||||||
|
│ ├── contracts/ # 四类源数据 TypeScript 契约
|
||||||
|
│ ├── data/ # 稳定 ID 数据解析入口
|
||||||
|
│ ├── layout/ # layout.json round-trip 与显式导出/恢复
|
||||||
|
│ ├── registry/ # 页面/Action 注册表唯一入口
|
||||||
|
│ ├── runtime/ # navigation runtime 与结构化条件
|
||||||
|
│ ├── studio/ # React Flow 画布说明
|
||||||
|
│ └── review/ # review.json 校验与导出
|
||||||
|
├── scripts/ # 命令、离线契约测试与增量回归
|
||||||
|
├── public/static/ # 只放本地、可版本化静态资源
|
||||||
|
├── package.json
|
||||||
|
└── package-lock.json
|
||||||
|
```
|
||||||
|
|
||||||
|
数据职责、ID 规则和边界见 [`references/data-contract.md`](references/data-contract.md)。注册表和
|
||||||
|
导航约束见 [`references/registry.md`](references/registry.md),命令/报告见
|
||||||
|
[`references/commands.md`](references/commands.md),增量修改见
|
||||||
|
[`references/incremental.md`](references/incremental.md),截图和报告见
|
||||||
|
[`references/artifacts.md`](references/artifacts.md)。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
请用 product-prototype 初始化一个设备管理原型,先保留 requirements、flow、layout、review
|
||||||
|
四类源数据和稳定 ID;生成离线模板和设备管理 mock,不连接真实设备,并按实际环境记录浏览器验证结果。
|
||||||
|
```
|
||||||
|
|
||||||
|
初始化后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd prototype-studio
|
||||||
|
npm install # 安装 package-lock 锁定的依赖
|
||||||
|
npm run init # 幂等补齐当前项目;已有文件只报告 skip
|
||||||
|
npm run dev # 启动 Vite(加载/依赖错误原样可见)
|
||||||
|
npm run validate -- --data-dir prototype
|
||||||
|
npm run snapshot # 启动 Vite + Chrome,写入 .prototype-artifacts/snapshots
|
||||||
|
npm run build # TypeScript noEmit + Vite production build
|
||||||
|
npm run test:incremental # 离线 flow/layout/review 新增、修改、删除边界检查
|
||||||
|
npm test # 离线合约 + mock + Playwright 关键交互与三视口 smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
`snapshot` 使用实际 Vite 页面和可发现的 Chrome,生成真实 PNG 及一一对应 manifest;浏览器
|
||||||
|
缺失、页面加载失败或产物不可写都会非零退出。Playwright 报告写入
|
||||||
|
`.prototype-artifacts/playwright-report`。测试只使用确定性本地 mock,不连接真实设备/后端。Playwright
|
||||||
|
使用一个 worker 显式覆盖 desktop/tablet/mobile;浏览器缺失、服务端口冲突、页面加载或断言失败
|
||||||
|
都必须记录为失败,不应跳过。
|
||||||
|
|
||||||
|
## 四类数据与审核
|
||||||
|
|
||||||
|
- `requirements.md` 描述目标、场景和验收上下文,供作者、Agent 和评审阅读。
|
||||||
|
- `flow.json` 声明 page/dialog/state 节点及 Action/条件边,是导航唯一事实来源;组件只能发出
|
||||||
|
registry 中的 Action ID。
|
||||||
|
- `layout.json` 独立保存 React Flow 坐标和 viewport。Studio 只在作者点击 `Save layout.json`
|
||||||
|
时导出,`Restore layout.json` 会先校验稳定 ID,不接受悬空节点。
|
||||||
|
- `review.json` 保存审核状态和绑定到 node/edge/page ID 的批注;导出前会再次拒绝悬空目标。
|
||||||
|
|
||||||
|
设备管理示例覆盖列表、新建、配置、连接中、成功、失败重试、详情和空列表,数据来自
|
||||||
|
`src/mocks/device-management.ts`,不访问真实设备、后端、凭据或 localStorage。
|
||||||
|
|
||||||
|
## 增量修改与删除边界
|
||||||
|
|
||||||
|
新增页面或 Action 时只添加对应节点/边、layout 坐标、相关 review 和实现;修改文案/组件时只
|
||||||
|
更新对应稳定 ID 及其相关截图/批注;删除前先检查并报告入边、出边、layout 坐标和 review 目标,
|
||||||
|
删除后不得留下悬空引用。数组排序或重新渲染不能造成无关 ID 重编号,冲突必须显式失败。
|
||||||
|
|
||||||
|
模板提供可离线复核的 fixture 检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd prototype-studio
|
||||||
|
npm run test:incremental
|
||||||
|
```
|
||||||
|
|
||||||
|
它同时比较 flow、layout、review 的 add/change/delete 结果,并断言 `page.keep`、无关坐标和
|
||||||
|
`comment.keep` 等未受影响记录保持不变;该检查是边界回归,不替代真实浏览器回归。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
至少应完成:`init` 幂等、`validate` 通过合法 fixture 且拒绝非法 fixture、增量检查通过、
|
||||||
|
`build` 通过,以及按实际环境记录 `snapshot`/Playwright 结果。真实截图必须是非空 PNG 且与
|
||||||
|
manifest 一一对应;受限宿主环境不能被写成浏览器测试通过。
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
- `init` 报目标不可写:换到可写的空目录,或修正目标目录权限;脚本不会提权或覆盖文件。
|
||||||
|
- `check_structure.py` 或 `check_docs.py` 报错:确认从仓库根目录运行,或用 `--skill-dir` 指向完整
|
||||||
|
Skill 目录;前者检查结构/链接,后者检查实现能力文档和过时占位措辞。
|
||||||
|
- npm 依赖安装失败:记录 Node/npm 版本和完整错误;不要把网络安装失败改写成模板验证通过。
|
||||||
|
- `BROWSER_MISSING`:安装 Chrome 或设置 `PROTOTYPE_CHROME_PATH=/绝对路径/google-chrome`,再重跑
|
||||||
|
`npm run snapshot` / `npm test`;不会跳过浏览器测试。
|
||||||
|
- `SNAPSHOT_FAILED`:检查 `.prototype-artifacts` 父目录可写、Vite 端口 4173 可用和页面
|
||||||
|
`data-testid`/flow 稳定 ID;Vite 使用 strict port,失败不会发布新的 generated manifest。
|
||||||
|
- `DEV_SERVER_TIMEOUT`、`DEV_SERVER_EXITED` 或 Playwright 残留:先停止占用 4173 的旧进程,再
|
||||||
|
重跑;snapshot/test 会在退出时关闭浏览器和 Vite,清理失败时应记录宿主环境状态。
|
||||||
|
- 画布没有业务节点:检查 `prototype/flow.json`、`layout.json` 和 registry 是否被 validate
|
||||||
|
接受;模板不从 localStorage 或后端补数据。
|
||||||
|
|
||||||
|
## 安全与回滚
|
||||||
|
|
||||||
|
模板默认不读取凭据、不访问网络、不连接真实设备,也不执行条件字符串。增量修改前保留 Git
|
||||||
|
提交或复制源数据作为回滚点;回滚应恢复同一批 requirements/flow/layout/review 与相关实现,
|
||||||
|
不要只回滚截图或构建产物。若模板不适用,移除或回退 `skills/product-prototype/` 即可;不要
|
||||||
|
修改 Agent 指令、其它 skill 或真实业务后端配置。
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: product-prototype
|
||||||
|
description: >-
|
||||||
|
将产品需求整理为可校验的文件型 prototype 数据,并复制或维护 React + TypeScript + React Flow
|
||||||
|
Prototype Studio。用户提出做高保真原型、页面动线、原型画布、React Flow 原型、交互预览、
|
||||||
|
原型截图/评审、requirements.md/flow.json/layout.json/review.json,或要求初始化可复用
|
||||||
|
Prototype Studio 模板时使用。不要用于真实后端、真实设备连接、生产 UI 编辑器或 Figma 双向同步。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Product Prototype
|
||||||
|
|
||||||
|
把需求、页面关系、布局和评审结论放进可版本化的 prototype 数据,再由本地 Prototype Studio
|
||||||
|
读取这些数据。Skill 的规范和模板位于本目录;项目初始化只复制模板和项目源数据,不在 Agent
|
||||||
|
目录写入普通 skill 文件。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
- 从产品需求建立 React + TypeScript + React Flow 原型项目。
|
||||||
|
- 需要页面、弹窗、状态节点和带 Action/条件的页面动线。
|
||||||
|
- 需要统一的 `requirements.md`、`flow.json`、`layout.json`、`review.json` 文件契约。
|
||||||
|
- 需要离线、确定性 mock 的原型预览、截图和评审记录。
|
||||||
|
|
||||||
|
## 不适用
|
||||||
|
|
||||||
|
- 真实设备、真实后端 API、账号权限、云端同步或线上部署。
|
||||||
|
- Figma 导入导出、多人协作或浏览器内生产级拖拽编辑器。
|
||||||
|
- 只有一张插画/营销页,或仅需要低保真 Draw.io 流程图(使用 `draw-prototype-flow`)。
|
||||||
|
|
||||||
|
## 当前能力与执行边界
|
||||||
|
|
||||||
|
1. 开始前读取 `references/data-contract.md`,并根据需要读取 `registry.md`、`commands.md`、
|
||||||
|
`incremental.md` 和 `artifacts.md`。
|
||||||
|
2. 使用 `scripts/init.py` 从 `assets/studio-template/` 复制项目;默认只创建缺失文件,绝不覆盖
|
||||||
|
用户已有的 requirements、flow、layout、review 或实现文件。模板内的 `npm run init` 提供相同的
|
||||||
|
幂等补齐能力。
|
||||||
|
3. `flow.json` 是导航唯一事实来源。页面组件只能调用注册表中的 Action ID;不得在组件里维护
|
||||||
|
第二套页面跳转映射。
|
||||||
|
4. 条件只能使用结构化白名单规则;禁止 `eval`、`Function` 或执行任意字符串表达式。
|
||||||
|
5. 当前模板已实现 React Flow Studio、共享 registry、NavigationRuntime、四类源数据 validator、
|
||||||
|
确定性设备 mock,以及 `init`/`dev`/`validate`/`snapshot`/`build`/`test` 命令。命令失败时
|
||||||
|
使用稳定错误码和非零退出码,不把缺浏览器、页面失败或宿主环境问题伪装成通过。
|
||||||
|
6. `snapshot` 使用真实 Vite 页面和可发现的 Chrome 生成 PNG 与 manifest;`test` 运行离线合约、
|
||||||
|
mock 检查和 Playwright 交互/三视口检查。测试产物只写入 `.prototype-artifacts/`。
|
||||||
|
7. 增量变更按稳定 ID 维护 flow、layout、review 和相关实现/截图;新增、修改、删除都必须同步
|
||||||
|
引用并做离线 diff,不能重编号或漂移无关节点。删除前要报告受影响的边、布局和批注。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:需求目标、用户场景、页面/状态清单、关键交互、验收上下文和可选的现有项目路径。
|
||||||
|
- 源数据:项目目录中的 `prototype/requirements.md`、`flow.json`、`layout.json`、`review.json`。
|
||||||
|
- 输出:可复制的 npm 项目、可追踪的稳定 ID、离线文档、可运行的 Studio/runtime 预览和可复核
|
||||||
|
的截图/测试产物。
|
||||||
|
- 生成物:截图、报告和测试输出只能写入项目约定的 artifacts 目录;它们不是源数据。
|
||||||
|
|
||||||
|
## 路由
|
||||||
|
|
||||||
|
- **初始化**:读取 `references/commands.md`,运行 `python3 scripts/init.py <target>`;先检查目标,
|
||||||
|
只补齐缺失文件,并报告 skipped/created。
|
||||||
|
- **检查**:运行 `python3 scripts/check_structure.py` 与 `python3 scripts/check_docs.py`,再按模板项目
|
||||||
|
README 的命令检查;需要浏览器的命令必须记录浏览器、页面和产物结果,不得伪造截图。
|
||||||
|
- **增量回归**:在模板根目录运行 `npm run test:incremental`,检查 flow/layout/review 的新增、
|
||||||
|
修改、删除 diff,并确认无关稳定 ID、坐标和批注保留。
|
||||||
|
- **实现/扩展**:先读对应 reference;所有页面与 Action 仍通过共享 registry 和 flow 驱动,禁止
|
||||||
|
在业务组件内新增第二套导航映射。真实后端、设备或外部服务需求先停下说明。
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- `SKILL.md` 的 frontmatter 名称与目录一致,README 和 references 链接可读。
|
||||||
|
- 模板有锁定的 npm 依赖声明、React/TypeScript/React Flow 入口、本地样式和四类源数据;Studio
|
||||||
|
可查看节点/边、预览 runtime、保存/恢复 layout 并导出 review。
|
||||||
|
- `init` 重复运行不会覆盖已有源数据;静态检查失败时返回非零码并指出文件。
|
||||||
|
- `validate`、离线合约、增量检查和 TypeScript/Vite build 可复核;浏览器截图与 Playwright 结果
|
||||||
|
必须按实际环境报告,未运行或因宿主环境失败的项目只能记录为 test gap。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
test-results/
|
||||||
|
.prototype-artifacts/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Validator and incremental fixtures
|
||||||
|
|
||||||
|
Each directory is a complete `prototype/`-shaped data set. Run
|
||||||
|
`npm run validate -- --data-dir fixtures/valid` for the passing fixture; the
|
||||||
|
`invalid-*` fixtures must fail with the named stable ID and error code.
|
||||||
|
|
||||||
|
`incremental/` contains the original stable-ID layout base/patch/expected files plus a flow/layout/review
|
||||||
|
add-change-delete fixture. Run `npm run test:incremental` from the template root to verify that only
|
||||||
|
declared IDs change, deleted IDs disappear, new IDs are present, and unrelated layout/review records stay
|
||||||
|
unchanged. The patch's `expected` coordinate makes concurrent edits fail closed rather than overwriting a
|
||||||
|
changed coordinate.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"flowVersion": 1,
|
||||||
|
"startNodeId": "page.keep",
|
||||||
|
"nodes": [
|
||||||
|
{ "id": "page.keep", "kind": "page", "component": "StudioPlaceholder", "title": "Keep" },
|
||||||
|
{ "id": "page.change", "kind": "page", "component": "StudioPlaceholder", "title": "Before" },
|
||||||
|
{ "id": "page.delete", "kind": "page", "component": "StudioPlaceholder", "title": "Remove" }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "id": "edge.keep", "source": "page.keep", "target": "page.change", "actionId": "action.studio.noop", "label": "Keep" },
|
||||||
|
{ "id": "edge.change", "source": "page.change", "target": "page.delete", "actionId": "action.studio.noop", "label": "Before" },
|
||||||
|
{ "id": "edge.delete", "source": "page.delete", "target": "page.keep", "actionId": "action.studio.noop", "label": "Remove" }
|
||||||
|
]
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"layoutVersion": 1,
|
||||||
|
"viewport": { "x": 0, "y": 0, "zoom": 1 },
|
||||||
|
"nodes": {
|
||||||
|
"page.keep": { "x": 100, "y": 200 },
|
||||||
|
"page.change": { "x": 300, "y": 400 },
|
||||||
|
"page.delete": { "x": 500, "y": 600 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{"page.start":{"x":10,"y":20},"page.keep":{"x":300,"y":400}}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"reviewVersion": 1,
|
||||||
|
"status": "draft",
|
||||||
|
"comments": [
|
||||||
|
{ "id": "comment.keep", "body": "Unrelated comment", "target": { "kind": "node", "id": "page.keep" } },
|
||||||
|
{ "id": "comment.change", "body": "Before", "target": { "kind": "node", "id": "page.change" } },
|
||||||
|
{ "id": "comment.delete", "body": "Remove", "target": { "kind": "node", "id": "page.delete" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"flowVersion": 1,
|
||||||
|
"startNodeId": "page.keep",
|
||||||
|
"nodes": [
|
||||||
|
{ "id": "page.keep", "kind": "page", "component": "StudioPlaceholder", "title": "Keep" },
|
||||||
|
{ "id": "page.change", "kind": "page", "component": "StudioPlaceholder", "title": "After" },
|
||||||
|
{ "id": "page.add", "kind": "state", "component": "StudioPlaceholder", "title": "Added" }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "id": "edge.keep", "source": "page.keep", "target": "page.change", "actionId": "action.studio.noop", "label": "Keep" },
|
||||||
|
{ "id": "edge.change", "source": "page.change", "target": "page.add", "actionId": "action.studio.noop", "label": "After" },
|
||||||
|
{ "id": "edge.add", "source": "page.add", "target": "page.keep", "actionId": "action.studio.noop", "label": "Added" }
|
||||||
|
]
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"layoutVersion": 1,
|
||||||
|
"viewport": { "x": 0, "y": 0, "zoom": 1 },
|
||||||
|
"nodes": {
|
||||||
|
"page.keep": { "x": 100, "y": 200 },
|
||||||
|
"page.change": { "x": 325, "y": 435 },
|
||||||
|
"page.add": { "x": 80, "y": 90 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{"page.start":{"x":15,"y":25},"page.keep":{"x":300,"y":400},"page.new":{"x":800,"y":900}}}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"reviewVersion": 1,
|
||||||
|
"status": "draft",
|
||||||
|
"comments": [
|
||||||
|
{ "id": "comment.keep", "body": "Unrelated comment", "target": { "kind": "node", "id": "page.keep" } },
|
||||||
|
{ "id": "comment.change", "body": "After", "target": { "kind": "node", "id": "page.change" } },
|
||||||
|
{ "id": "comment.add", "body": "Added", "target": { "kind": "node", "id": "page.add" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"update": [{ "id": "page.change", "patch": { "title": "After" } }],
|
||||||
|
"removeIds": ["page.delete"],
|
||||||
|
"add": [{ "id": "page.add", "kind": "state", "component": "StudioPlaceholder", "title": "Added" }]
|
||||||
|
},
|
||||||
|
"edges": {
|
||||||
|
"update": [{ "id": "edge.change", "patch": { "target": "page.add", "label": "After" } }],
|
||||||
|
"removeIds": ["edge.delete"],
|
||||||
|
"add": [{ "id": "edge.add", "source": "page.add", "target": "page.keep", "actionId": "action.studio.noop", "label": "Added" }]
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"update": [{ "id": "comment.change", "patch": { "body": "After" } }],
|
||||||
|
"removeIds": ["comment.delete"],
|
||||||
|
"add": [{ "id": "comment.add", "body": "Added", "target": { "kind": "node", "id": "page.add" } }]
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"nodes": {
|
||||||
|
"page.change": { "x": 325, "y": 435, "expected": { "x": 300, "y": 400 } },
|
||||||
|
"page.add": { "x": 80, "y": 90 }
|
||||||
|
},
|
||||||
|
"removeNodeIds": ["page.delete"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"nodes":{"page.start":{"x":15,"y":25,"expected":{"x":10,"y":20}},"page.new":{"x":800,"y":900}},"removeNodeIds":["page.deleted"]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"},{"id":"page.end","kind":"page","component":"StudioPlaceholder","title":"B"}],"edges":[{"id":"edge.bad-condition","source":"page.start","target":"page.end","actionId":"action.studio.noop","when":"device.status === 'connected'"}]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
## Goal
|
||||||
|
conditions
|
||||||
|
## Scenario
|
||||||
|
test
|
||||||
|
## Acceptance context
|
||||||
|
test
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"}],"edges":[{"id":"edge.missing","source":"page.start","target":"page.missing","actionId":"action.studio.noop"}]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
## Goal
|
||||||
|
references
|
||||||
|
## Scenario
|
||||||
|
test
|
||||||
|
## Acceptance context
|
||||||
|
test
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"},{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"B"}],"edges":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
## Goal
|
||||||
|
duplicate IDs
|
||||||
|
## Scenario
|
||||||
|
test
|
||||||
|
## Acceptance context
|
||||||
|
test
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"}],"edges":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{"page.missing":{"x":1,"y":2}}}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
## Goal
|
||||||
|
layout refs
|
||||||
|
## Scenario
|
||||||
|
test
|
||||||
|
## Acceptance context
|
||||||
|
test
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"}],"edges":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
## Goal
|
||||||
|
review refs
|
||||||
|
## Scenario
|
||||||
|
test
|
||||||
|
## Acceptance context
|
||||||
|
test
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[{"id":"comment.missing","body":"Missing target","target":{"kind":"node","id":"page.missing"}}]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"},{"id":"page.end","kind":"page","component":"StudioPlaceholder","title":"B"}],"edges":[{"id":"edge.unknown-action","source":"page.start","target":"page.end","actionId":"action.missing"}]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
## Goal
|
||||||
|
registry
|
||||||
|
## Scenario
|
||||||
|
test
|
||||||
|
## Acceptance context
|
||||||
|
test
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"ComponentMissing","title":"A"}],"edges":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
## Goal
|
||||||
|
registry
|
||||||
|
## Scenario
|
||||||
|
test
|
||||||
|
## Acceptance context
|
||||||
|
test
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"},{"id":"page.orphan","kind":"page","component":"StudioPlaceholder","title":"Orphan"}],"edges":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
## Goal
|
||||||
|
reachability
|
||||||
|
## Scenario
|
||||||
|
test
|
||||||
|
## Acceptance context
|
||||||
|
test
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"flowVersion":1,"startNodeId":"page.studio-placeholder","nodes":[{"id":"page.studio-placeholder","kind":"page","component":"StudioPlaceholder","title":"Studio"}],"edges":[]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{"page.studio-placeholder":{"x":80,"y":80}}}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Keep a versioned prototype graph.
|
||||||
|
|
||||||
|
## Scenario
|
||||||
|
|
||||||
|
An author validates source data offline.
|
||||||
|
|
||||||
|
## Acceptance context
|
||||||
|
|
||||||
|
Validation is deterministic and reports stable IDs.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reviewVersion":1,"status":"draft","comments":[]}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#152238" />
|
||||||
|
<title>Prototype Studio</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "prototype-studio-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@10",
|
||||||
|
"description": "Offline React + TypeScript + React Flow Prototype Studio skeleton",
|
||||||
|
"scripts": {
|
||||||
|
"init": "node scripts/init.mjs",
|
||||||
|
"dev": "node scripts/dev.mjs",
|
||||||
|
"validate": "node scripts/validate.mjs",
|
||||||
|
"snapshot": "node scripts/snapshot.mjs",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"test:incremental": "node scripts/incremental-check.mjs",
|
||||||
|
"test": "node scripts/test.mjs",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/react": "12.3.1",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.55.0",
|
||||||
|
"@types/node": "22.10.2",
|
||||||
|
"@types/react": "18.3.12",
|
||||||
|
"@types/react-dom": "18.3.1",
|
||||||
|
"@vitejs/plugin-react": "4.3.3",
|
||||||
|
"typescript": "5.6.3",
|
||||||
|
"vite": "5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
function findChrome(): string | undefined {
|
||||||
|
if (process.env.PROTOTYPE_CHROME_PATH) return process.env.PROTOTYPE_CHROME_PATH;
|
||||||
|
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim(); } catch { return undefined; }
|
||||||
|
}
|
||||||
|
const chrome = findChrome();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
timeout: 30_000,
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: Boolean(process.env.CI),
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: [["line"], ["html", { outputFolder: ".prototype-artifacts/playwright-report", open: "never" }]],
|
||||||
|
use: { baseURL: ["http", "://", "127.0.0.1:4173"].join(""), ...(chrome ? { launchOptions: { executablePath: chrome, args: ["--disable-gpu"] } } : { channel: "chrome" }), headless: true, trace: "retain-on-failure", video: "off", screenshot: "only-on-failure" },
|
||||||
|
webServer: { command: "node scripts/test-server.mjs", url: ["http", "://", "127.0.0.1:4173"].join(""), reuseExistingServer: false, timeout: 30_000 },
|
||||||
|
// Keep one browser worker: the viewport matrix is exercised explicitly in the smoke test,
|
||||||
|
// avoiding three concurrent Chrome instances on constrained CI runners.
|
||||||
|
projects: [{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } }],
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"flowVersion": 1,
|
||||||
|
"startNodeId": "page.studio-placeholder",
|
||||||
|
"nodes": [
|
||||||
|
{ "id": "page.studio-placeholder", "kind": "page", "component": "StudioPlaceholder", "title": "Studio overview", "description": "A page node with actions from the shared flow.", "thumbnail": "/static/studio-overview.svg" },
|
||||||
|
{ "id": "dialog.studio-help", "kind": "dialog", "component": "StudioPlaceholder", "title": "Help dialog", "description": "A dialog node rendered from the same registry.", "thumbnail": "/static/studio-help.svg" },
|
||||||
|
{ "id": "state.studio-empty", "kind": "state", "component": "StudioPlaceholder", "title": "Empty state", "description": "A state node used to demonstrate conditional navigation.", "thumbnail": "/static/studio-empty.svg" },
|
||||||
|
{ "id": "page.device-list", "kind": "page", "component": "DeviceList", "title": "设备列表", "description": "设备管理高保真示例:查看设备、创建设备或预览空列表。" },
|
||||||
|
{ "id": "page.device-create", "kind": "page", "component": "DeviceCreate", "title": "新建设备", "description": "填写设备基础信息。" },
|
||||||
|
{ "id": "page.device-config", "kind": "page", "component": "DeviceConfig", "title": "配置连接参数", "description": "填写并保存设备连接参数。" },
|
||||||
|
{ "id": "state.device-connecting", "kind": "state", "component": "DeviceConnecting", "title": "正在连接设备", "description": "运行确定性连接 mock。" },
|
||||||
|
{ "id": "state.device-connected", "kind": "state", "component": "DeviceConnected", "title": "设备连接成功", "description": "连接成功后查看设备详情。" },
|
||||||
|
{ "id": "state.device-connection-failed", "kind": "state", "component": "DeviceConnectionFailed", "title": "连接失败", "description": "显示失败原因并支持重试。" },
|
||||||
|
{ "id": "state.device-empty", "kind": "state", "component": "DeviceEmpty", "title": "设备列表为空", "description": "无设备时的确定性空状态。" },
|
||||||
|
{ "id": "page.device-detail", "kind": "page", "component": "DeviceDetail", "title": "设备详情", "description": "查看连接成功设备的详细信息。" }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "id": "edge.studio.open-help", "source": "page.studio-placeholder", "target": "dialog.studio-help", "actionId": "action.studio.open-help", "label": "Open help" },
|
||||||
|
{ "id": "edge.studio.show-empty", "source": "page.studio-placeholder", "target": "state.studio-empty", "actionId": "action.studio.show-empty", "label": "Show empty", "when": { "op": "truthy", "field": "showEmpty" } },
|
||||||
|
{ "id": "edge.studio.close-help", "source": "dialog.studio-help", "target": "page.studio-placeholder", "actionId": "action.studio.close-help", "label": "Close" },
|
||||||
|
{ "id": "edge.studio.back", "source": "state.studio-empty", "target": "page.studio-placeholder", "actionId": "action.studio.back", "label": "Back" },
|
||||||
|
{ "id": "edge.studio.open-device-management", "source": "page.studio-placeholder", "target": "page.device-list", "actionId": "device.open-management", "label": "打开设备管理" },
|
||||||
|
{ "id": "edge.device.create", "source": "page.device-list", "target": "page.device-create", "actionId": "device.create", "label": "新建设备" },
|
||||||
|
{ "id": "edge.device.show-empty", "source": "page.device-list", "target": "state.device-empty", "actionId": "device.show-empty", "label": "预览空列表", "when": { "op": "eq", "field": "deviceScenario", "value": "empty" } },
|
||||||
|
{ "id": "edge.device.open-detail", "source": "page.device-list", "target": "page.device-detail", "actionId": "device.open-detail", "label": "查看详情", "when": { "op": "truthy", "field": "deviceExists" } },
|
||||||
|
{ "id": "edge.device.create-to-list", "source": "page.device-create", "target": "page.device-list", "actionId": "device.back-to-list", "label": "取消" },
|
||||||
|
{ "id": "edge.device.create-to-config", "source": "page.device-create", "target": "page.device-config", "actionId": "device.continue-to-config", "label": "继续配置", "when": { "op": "truthy", "field": "formValid" } },
|
||||||
|
{ "id": "edge.device.config-to-create", "source": "page.device-config", "target": "page.device-create", "actionId": "device.back-to-create", "label": "返回上一步" },
|
||||||
|
{ "id": "edge.device.config-to-connecting", "source": "page.device-config", "target": "state.device-connecting", "actionId": "device.save-config", "label": "保存并连接", "when": { "op": "truthy", "field": "formValid" } },
|
||||||
|
{ "id": "edge.device.connecting-success", "source": "state.device-connecting", "target": "state.device-connected", "actionId": "device.resolve-connection", "label": "连接成功", "when": { "op": "eq", "field": "connectionOutcome", "value": "success" } },
|
||||||
|
{ "id": "edge.device.connecting-failure", "source": "state.device-connecting", "target": "state.device-connection-failed", "actionId": "device.resolve-connection", "label": "连接失败", "when": { "op": "eq", "field": "connectionOutcome", "value": "failure" } },
|
||||||
|
{ "id": "edge.device.connected-detail", "source": "state.device-connected", "target": "page.device-detail", "actionId": "device.open-detail", "label": "查看详情", "when": { "op": "truthy", "field": "deviceExists" } },
|
||||||
|
{ "id": "edge.device.failed-retry", "source": "state.device-connection-failed", "target": "state.device-connecting", "actionId": "device.retry-connection", "label": "重试连接", "when": { "op": "truthy", "field": "canRetry" } },
|
||||||
|
{ "id": "edge.device.failed-list", "source": "state.device-connection-failed", "target": "page.device-list", "actionId": "device.back-to-list", "label": "返回列表" },
|
||||||
|
{ "id": "edge.device.empty-create", "source": "state.device-empty", "target": "page.device-create", "actionId": "device.create", "label": "新建设备" },
|
||||||
|
{ "id": "edge.device.detail-list", "source": "page.device-detail", "target": "page.device-list", "actionId": "device.back-to-list", "label": "返回列表" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"layoutVersion": 1,
|
||||||
|
"viewport": { "x": 0, "y": 0, "zoom": 1 },
|
||||||
|
"nodes": {
|
||||||
|
"page.studio-placeholder": { "x": 80, "y": 80 },
|
||||||
|
"dialog.studio-help": { "x": 420, "y": 40 },
|
||||||
|
"state.studio-empty": { "x": 420, "y": 260 },
|
||||||
|
"page.device-list": { "x": 760, "y": 80 },
|
||||||
|
"page.device-create": { "x": 1080, "y": 80 },
|
||||||
|
"page.device-config": { "x": 1400, "y": 80 },
|
||||||
|
"state.device-connecting": { "x": 1720, "y": 80 },
|
||||||
|
"state.device-connected": { "x": 2040, "y": 80 },
|
||||||
|
"state.device-connection-failed": { "x": 2040, "y": 320 },
|
||||||
|
"state.device-empty": { "x": 1080, "y": 420 },
|
||||||
|
"page.device-detail": { "x": 2360, "y": 80 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Prototype requirements
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
用本地 Prototype Studio 维护页面关系、布局和评审记录。
|
||||||
|
|
||||||
|
## Scenario
|
||||||
|
|
||||||
|
作者从一份需求初始化 Studio,使用共享 registry 和 flow Action 查看页面动线、runtime 预览、
|
||||||
|
设备管理 mock、layout 保存和 review 批注导出。
|
||||||
|
|
||||||
|
## Acceptance context
|
||||||
|
|
||||||
|
验收包括四类源数据和稳定 ID、schema/引用/可达性校验、增量边界、幂等初始化、构建,以及按
|
||||||
|
实际环境记录的 snapshot/Playwright 结果;不连接真实后端或设备。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"reviewVersion": 1,
|
||||||
|
"status": "draft",
|
||||||
|
"comments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Offline static assets
|
||||||
|
|
||||||
|
只放项目自有、可版本化的本地静态资源。模板不下载或引用远程 CSS、字体、图片、图标 CDN 或
|
||||||
|
运行时服务;空目录由此说明文件保留。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="120" viewBox="0 0 420 120"><rect width="420" height="120" rx="10" fill="#152e31"/><circle cx="210" cy="48" r="24" fill="none" stroke="#63e6be" stroke-width="4"/><path d="M190 83h40" stroke="#63e6be" stroke-width="5" stroke-linecap="round"/><rect x="110" y="98" width="200" height="5" rx="2" fill="#376d6d"/></svg>
|
||||||
|
After Width: | Height: | Size: 374 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="120" viewBox="0 0 420 120"><rect width="420" height="120" rx="10" fill="#271d35"/><rect x="60" y="18" width="300" height="84" rx="8" fill="#47304d" stroke="#f783ac" stroke-width="3"/><circle cx="105" cy="60" r="20" fill="#f783ac"/><path d="M105 48v25m0 11v2" stroke="#271d35" stroke-width="5"/><rect x="145" y="40" width="160" height="8" rx="4" fill="#f5bbcf"/><rect x="145" y="59" width="120" height="8" rx="4" fill="#a57991"/></svg>
|
||||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="120" viewBox="0 0 420 120"><rect width="420" height="120" rx="10" fill="#172940"/><rect x="20" y="20" width="110" height="80" rx="5" fill="#29496b"/><rect x="148" y="20" width="110" height="12" rx="4" fill="#74c0fc"/><rect x="148" y="43" width="180" height="8" rx="4" fill="#50708e"/><rect x="148" y="62" width="150" height="8" rx="4" fill="#385a78"/><circle cx="350" cy="62" r="28" fill="#204768" stroke="#74c0fc" stroke-width="3"/></svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const vite = path.join(root, "node_modules", "vite", "bin", "vite.js");
|
||||||
|
if (!fs.existsSync(vite)) { console.error(`DEV_DEPENDENCY_MISSING: Vite is not installed at ${vite}`); process.exit(2); }
|
||||||
|
const child = spawn(process.execPath, [vite, ...process.argv.slice(2)], { cwd: root, stdio: "inherit", env: { ...process.env, BROWSER: "none" } });
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
|
||||||
|
child.on("error", (error) => { console.error(`DEV_START_FAILED: ${error.message}`); process.exitCode = 2; });
|
||||||
|
child.on("exit", (code, signal) => { process.exitCode = code ?? (signal ? 2 : 0); });
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Offline regression for the stable-ID incremental data boundary.
|
||||||
|
*
|
||||||
|
* This is deliberately a fixture check, not a second product merge engine:
|
||||||
|
* it proves the documented add/change/delete diff contract without changing
|
||||||
|
* the Studio/runtime implementation.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const fixtureRoot = path.join(root, "fixtures", "incremental");
|
||||||
|
|
||||||
|
function read(name) {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(fixtureRoot, name), "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function equal(left, right) {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexById(items) {
|
||||||
|
return new Map(items.map((item) => [item.id, item]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCollection(baseItems, patch) {
|
||||||
|
const items = clone(baseItems);
|
||||||
|
const remove = new Set(patch.removeIds ?? []);
|
||||||
|
const updates = new Map((patch.update ?? []).map((item) => [item.id, item.patch]));
|
||||||
|
const added = new Set((patch.add ?? []).map((item) => item.id));
|
||||||
|
const result = items
|
||||||
|
.filter((item) => !remove.has(item.id))
|
||||||
|
.map((item) => (updates.has(item.id) ? { ...item, ...updates.get(item.id) } : item));
|
||||||
|
result.push(...clone(patch.add ?? []));
|
||||||
|
return { result, remove, updates, added };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayout(base, patch) {
|
||||||
|
const result = clone(base);
|
||||||
|
for (const id of patch.removeNodeIds ?? []) delete result.nodes[id];
|
||||||
|
for (const [id, position] of Object.entries(patch.nodes ?? {})) {
|
||||||
|
const { expected: _expected, ...next } = position;
|
||||||
|
result.nodes[id] = next;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseFlow = read("base-flow.json");
|
||||||
|
const patch = read("patch-data.json");
|
||||||
|
const expectedFlow = read("expected-flow.json");
|
||||||
|
const baseLayout = read("base-layout-scope.json");
|
||||||
|
const expectedLayout = read("expected-layout-scope.json");
|
||||||
|
const baseReview = read("base-review.json");
|
||||||
|
const expectedReview = read("expected-review.json");
|
||||||
|
|
||||||
|
const flowNodes = applyCollection(baseFlow.nodes, patch.nodes);
|
||||||
|
const flowEdges = applyCollection(baseFlow.edges, patch.edges);
|
||||||
|
const reviewComments = applyCollection(baseReview.comments, patch.comments);
|
||||||
|
const actualFlow = { ...baseFlow, nodes: flowNodes.result, edges: flowEdges.result };
|
||||||
|
const actualReview = { ...baseReview, comments: reviewComments.result };
|
||||||
|
const actualLayout = applyLayout(baseLayout, patch.layout);
|
||||||
|
|
||||||
|
assert(equal(actualFlow, expectedFlow), "flow add/change/delete diff does not match expected fixture");
|
||||||
|
assert(equal(actualLayout, expectedLayout), "layout add/change/delete diff does not match expected fixture");
|
||||||
|
assert(equal(actualReview, expectedReview), "review add/change/delete diff does not match expected fixture");
|
||||||
|
|
||||||
|
for (const [name, baseItems, actualItems, patchInfo] of [
|
||||||
|
["flow node", baseFlow.nodes, actualFlow.nodes, flowNodes],
|
||||||
|
["flow edge", baseFlow.edges, actualFlow.edges, flowEdges],
|
||||||
|
["review comment", baseReview.comments, actualReview.comments, reviewComments],
|
||||||
|
]) {
|
||||||
|
const actualById = indexById(actualItems);
|
||||||
|
for (const item of baseItems) {
|
||||||
|
if (patchInfo.remove.has(item.id) || patchInfo.updates.has(item.id)) continue;
|
||||||
|
assert(equal(actualById.get(item.id), item), `${name} ${item.id} changed outside the patch`);
|
||||||
|
}
|
||||||
|
for (const id of patchInfo.remove) assert(!actualById.has(id), `${name} ${id} was not removed`);
|
||||||
|
for (const id of patchInfo.added) assert(actualById.has(id), `${name} ${id} was not added`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of patch.layout.removeNodeIds ?? []) assert(!(id in actualLayout.nodes), `layout ${id} was not removed`);
|
||||||
|
for (const id of ["page.keep", "page.change"]) {
|
||||||
|
if (!(id in (patch.layout.nodes ?? {}))) assert(equal(actualLayout.nodes[id], baseLayout.nodes[id]), `layout ${id} changed outside the patch`);
|
||||||
|
}
|
||||||
|
for (const id of ["comment.keep", "comment.change"]) {
|
||||||
|
const comment = actualReview.comments.find((item) => item.id === id);
|
||||||
|
assert(Boolean(comment), `review ${id} disappeared unexpectedly`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("incremental scope check: PASS (flow/layout/review add-change-delete; unrelated stable IDs preserved)");
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Idempotently copy this template, never overwriting an existing file. */
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const excluded = new Set(["node_modules", "dist", "test-results", ".prototype-artifacts"]);
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes("--help") || args.includes("-h")) { console.log("Usage: npm run init -- [TARGET_DIR]"); process.exit(0); }
|
||||||
|
if (args.length > 1 || args.some((arg) => arg.startsWith("-"))) fail("INIT_ARGUMENT", "expected zero or one target directory");
|
||||||
|
const target = path.resolve(args[0] ?? ".");
|
||||||
|
if (args.length && (target === root || root.startsWith(`${target}${path.sep}`))) fail("INIT_TARGET", "target must not be the template directory or its parent");
|
||||||
|
if (fs.existsSync(target) && !fs.statSync(target).isDirectory()) fail("INIT_TARGET", `target is not a directory: ${target}`);
|
||||||
|
try { fs.mkdirSync(target, { recursive: true }); } catch (error) { fail("INIT_WRITE", `cannot create target ${target}: ${error.message}`); }
|
||||||
|
const created = [];
|
||||||
|
const skipped = [];
|
||||||
|
copyTree(root, target, root);
|
||||||
|
console.log(`Prototype Studio init: ${target}`);
|
||||||
|
console.log(`created: ${created.length}`);
|
||||||
|
for (const item of created) console.log(` create ${item}`);
|
||||||
|
console.log(`skipped: ${skipped.length}`);
|
||||||
|
for (const item of skipped) console.log(` skip ${item}`);
|
||||||
|
|
||||||
|
function copyTree(source, destination, sourceRoot) {
|
||||||
|
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
||||||
|
if (excluded.has(entry.name) || entry.name === ".git") continue;
|
||||||
|
const from = path.join(source, entry.name); const to = path.join(destination, entry.name);
|
||||||
|
const relative = path.relative(sourceRoot, from) || entry.name;
|
||||||
|
if (entry.isDirectory()) { fs.mkdirSync(to, { recursive: true }); copyTree(from, to, sourceRoot); continue; }
|
||||||
|
if (fs.existsSync(to)) { skipped.push(relative); continue; }
|
||||||
|
try { fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(from, to); created.push(relative); }
|
||||||
|
catch (error) { fail("INIT_WRITE", `cannot copy ${relative}: ${error.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fail(code, message) { console.error(`${code}: ${message}`); process.exit(2); }
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
import { startViteServer, stopViteServer } from "./vite-server.mjs";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const viewports = [{ name: "desktop", width: 1440, height: 900 }, { name: "tablet", width: 1024, height: 900 }, { name: "mobile", width: 390, height: 844 }];
|
||||||
|
const pages = ["list", "create", "config", "connecting", "connected", "failed", "detail", "empty"];
|
||||||
|
const outputRoot = path.resolve(process.cwd(), process.env.PROTOTYPE_ARTIFACTS ?? ".prototype-artifacts");
|
||||||
|
const outputDir = path.join(outputRoot, "snapshots"); const manifestPath = path.join(outputDir, "device-management.manifest.json"); const staging = `${outputDir}.staging-${process.pid}`;
|
||||||
|
let server; let browser;
|
||||||
|
let shuttingDown = false;
|
||||||
|
async function cleanup() {
|
||||||
|
const currentBrowser = browser; browser = undefined;
|
||||||
|
const currentServer = server; server = undefined;
|
||||||
|
await currentBrowser?.close();
|
||||||
|
await stopViteServer(currentServer?.child);
|
||||||
|
}
|
||||||
|
for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143]]) process.once(signal, () => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
void cleanup().finally(() => process.exit(code));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await fs.rm(staging, { recursive: true, force: true }); await fs.mkdir(staging, { recursive: true });
|
||||||
|
server = await startViteServer(root);
|
||||||
|
try { browser = await chromium.launch({ executablePath: findChrome(), headless: true, args: ["--disable-gpu"] }); } catch (error) { fail("BROWSER_MISSING", `Chrome/Playwright browser unavailable: ${error.message}`); }
|
||||||
|
const screenshots = [];
|
||||||
|
for (const viewport of viewports) for (const scene of pages) {
|
||||||
|
const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height } });
|
||||||
|
try {
|
||||||
|
await driveToScene(page, server.url, scene);
|
||||||
|
const testId = await page.locator("[data-testid^='device-page-']").getAttribute("data-testid");
|
||||||
|
if (!testId) fail("PAGE_NOT_REGISTERED", `scene ${scene} did not render a registered device page`);
|
||||||
|
const file = `device-management.${scene}.${viewport.name}.png`; const filePath = path.join(staging, file);
|
||||||
|
await page.screenshot({ path: filePath, fullPage: true, animations: "disabled" }); const stat = await fs.stat(filePath);
|
||||||
|
if (!stat.size) fail("SCREENSHOT_EMPTY", `screenshot is empty: ${file}`);
|
||||||
|
screenshots.push({ scene: "device-management", scenario: scene === "failed" ? "retry-success" : scene === "empty" ? "empty" : "normal", page: scene, nodeId: testId.replace("device-page-", ""), viewport, path: `snapshots/${file}`, bytes: stat.size });
|
||||||
|
} finally { await page.close(); }
|
||||||
|
}
|
||||||
|
if (screenshots.length !== viewports.length * pages.length) fail("MANIFEST_INCOMPLETE", "not every page/viewport pair produced a screenshot");
|
||||||
|
const manifest = { version: 1, status: "generated", generatedBy: "npm run snapshot", baseUrl: server.url, screenshots };
|
||||||
|
await fs.mkdir(outputDir, { recursive: true }); await fs.rm(outputDir, { recursive: true, force: true }); await fs.rename(staging, outputDir); await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
console.log(`snapshot: OK (${screenshots.length} actual screenshots)`); console.log(`manifest: ${manifestPath}`);
|
||||||
|
} catch (error) { console.error(`${error.code ?? "SNAPSHOT_FAILED"}: ${error.message}`); await fs.rm(staging, { recursive: true, force: true }); process.exitCode = 1; }
|
||||||
|
finally { if (!shuttingDown) await cleanup(); }
|
||||||
|
|
||||||
|
async function driveToScene(page, url, scene) {
|
||||||
|
await page.goto(url, { waitUntil: "networkidle", timeout: 20_000 }); await page.locator("[data-testid='flow-node-page.studio-placeholder']").waitFor({ state: "attached", timeout: 10_000 });
|
||||||
|
if (scene === "failed") await page.getByTestId("device-scenario").selectOption("retry-success", { force: true });
|
||||||
|
await page.getByTestId("dispatch-device.open-management").click(); await page.getByTestId("device-page-list").waitFor();
|
||||||
|
if (scene === "list") return; if (scene === "empty") { await page.getByTestId("device-scenario").selectOption("empty", { force: true }); return; }
|
||||||
|
await page.getByTestId("device-action-device.create").click(); await page.getByTestId("device-page-create").waitFor(); if (scene === "create") return;
|
||||||
|
if (scene === "config") { await page.getByTestId("device-name").fill("截图设备"); await page.getByTestId("device-host").fill("10.0.0.8"); await page.getByTestId("device-action-device.continue-to-config").click(); await page.getByTestId("device-page-config").waitFor(); return; }
|
||||||
|
await page.getByTestId("device-name").fill("截图设备"); await page.getByTestId("device-host").fill("10.0.0.8"); await page.getByTestId("device-action-device.continue-to-config").click(); await page.getByTestId("device-page-config").waitFor();
|
||||||
|
if (scene === "connecting") { await page.getByTestId("device-action-device.save-config").click(); await page.getByTestId("device-page-connecting").waitFor(); return; }
|
||||||
|
if (scene === "failed") { await page.getByTestId("device-action-device.save-config").click(); await page.getByTestId("device-action-device.resolve-connection").click(); await page.getByTestId("device-page-failed").waitFor(); return; }
|
||||||
|
await page.getByTestId("device-action-device.save-config").click(); await page.getByTestId("device-action-device.resolve-connection").click(); await page.getByTestId("device-page-connected").waitFor(); if (scene === "connected") return;
|
||||||
|
await page.getByTestId("device-action-device.open-detail").click(); await page.getByTestId("device-page-detail").waitFor();
|
||||||
|
}
|
||||||
|
function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
|
||||||
|
function findChrome() {
|
||||||
|
if (process.env.PROTOTYPE_CHROME_PATH) return process.env.PROTOTYPE_CHROME_PATH;
|
||||||
|
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim(); } catch { return undefined; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { startViteServer, stopViteServer } from "./vite-server.mjs";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
let server;
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
async function shutdown(code) {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
await stopViteServer(server?.child);
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once("SIGINT", () => void shutdown(130));
|
||||||
|
process.once("SIGTERM", () => void shutdown(143));
|
||||||
|
|
||||||
|
try {
|
||||||
|
server = await startViteServer(root);
|
||||||
|
await new Promise(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
await stopViteServer(server?.child);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Offline validator, Studio/runtime, device mock, and browser contract checks. */
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const validator = path.join(root, "scripts", "validate.mjs");
|
||||||
|
const fixtures = path.join(root, "fixtures");
|
||||||
|
const run = (name) => spawnSync(process.execPath, [validator, "--data-dir", path.join(fixtures, name), "--json"], { encoding: "utf8" });
|
||||||
|
const failures = [];
|
||||||
|
const valid = run("valid");
|
||||||
|
if (valid.status !== 0 || !JSON.parse(valid.stdout).valid) failures.push("valid fixture should pass");
|
||||||
|
for (const [name, code, id] of [
|
||||||
|
["invalid-duplicate-id", "DUPLICATE_ID", "page.start"],
|
||||||
|
["invalid-dangling-flow", "DANGLING_REFERENCE", "edge.missing"],
|
||||||
|
["invalid-condition", "INVALID_CONDITION", "edge.bad-condition"],
|
||||||
|
["invalid-unreachable", "UNREACHABLE_NODE", "page.orphan"],
|
||||||
|
["invalid-unknown-action", "UNKNOWN_ACTION", "action.missing"],
|
||||||
|
["invalid-unknown-component", "UNKNOWN_COMPONENT", "page.start"],
|
||||||
|
["invalid-layout-reference", "DANGLING_REFERENCE", "page.missing"],
|
||||||
|
["invalid-review-reference", "DANGLING_REFERENCE", "page.missing"],
|
||||||
|
]) {
|
||||||
|
const result = run(name);
|
||||||
|
let report;
|
||||||
|
try { report = JSON.parse(result.stdout); } catch (_error) { report = { errors: [] }; }
|
||||||
|
if (result.status === 0 || !report.errors.some((error) => error.code === code && error.id === id && error.file)) failures.push(`${name} should report ${code} for ${id} with file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = JSON.parse(fs.readFileSync(path.join(fixtures, "incremental", "base-layout.json"), "utf8"));
|
||||||
|
const patch = JSON.parse(fs.readFileSync(path.join(fixtures, "incremental", "patch-layout.json"), "utf8"));
|
||||||
|
const expected = JSON.parse(fs.readFileSync(path.join(fixtures, "incremental", "expected-layout.json"), "utf8"));
|
||||||
|
const merged = { ...base, nodes: { ...base.nodes } };
|
||||||
|
for (const id of patch.removeNodeIds ?? []) delete merged.nodes[id];
|
||||||
|
for (const [id, position] of Object.entries(patch.nodes ?? {})) {
|
||||||
|
const current = merged.nodes[id];
|
||||||
|
if (position.expected && current && (position.expected.x !== current.x || position.expected.y !== current.y)) failures.push(`layout conflict for ${id} was not detected`);
|
||||||
|
const { expected: _expected, ...next } = position;
|
||||||
|
merged.nodes[id] = next;
|
||||||
|
}
|
||||||
|
if (JSON.stringify(merged) !== JSON.stringify(expected)) failures.push("incremental layout merge changed an unrelated stable node");
|
||||||
|
|
||||||
|
const prototypeFlow = JSON.parse(fs.readFileSync(path.join(root, "prototype", "flow.json"), "utf8"));
|
||||||
|
const registry = JSON.parse(fs.readFileSync(path.join(root, "src", "registry", "registry.json"), "utf8"));
|
||||||
|
const kinds = new Set(prototypeFlow.nodes.map((node) => node.kind));
|
||||||
|
for (const kind of ["page", "dialog", "state"]) if (!kinds.has(kind)) failures.push(`T3 prototype must include ${kind} node`);
|
||||||
|
for (const edge of prototypeFlow.edges) {
|
||||||
|
if (!registry.actions[edge.actionId]) failures.push(`T3 edge ${edge.id} has no registry Action`);
|
||||||
|
if (edge.label === undefined) failures.push(`T3 edge ${edge.id} should expose a label`);
|
||||||
|
}
|
||||||
|
const appSource = fs.readFileSync(path.join(root, "src", "App.tsx"), "utf8");
|
||||||
|
for (const marker of ["<MiniMap", "<Controls", "MarkerType.ArrowClosed", "Save layout.json", "Restore layout.json", "Export review.json", "data-testid={`dispatch-"]) if (!appSource.includes(marker)) failures.push(`T3 Studio marker missing: ${marker}`);
|
||||||
|
const runtimeSource = fs.readFileSync(path.join(root, "src", "runtime", "index.ts"), "utf8");
|
||||||
|
for (const forbidden of ["localStorage", "eval(", "new Function("]) if (runtimeSource.includes(forbidden)) failures.push(`T3 runtime must not use ${forbidden}`);
|
||||||
|
for (const marker of ["DANGLING_TARGET", "SOURCE_MISMATCH", "CONDITION_NOT_MET", "getAction(actionId)"]) if (!runtimeSource.includes(marker)) failures.push(`T3 runtime guard missing: ${marker}`);
|
||||||
|
const deviceFlow = JSON.parse(fs.readFileSync(path.join(root, "prototype", "flow.json"), "utf8"));
|
||||||
|
const deviceNodeIds = new Set(deviceFlow.nodes.map((node) => node.id));
|
||||||
|
for (const id of ["page.device-list", "page.device-create", "page.device-config", "state.device-connecting", "state.device-connected", "state.device-connection-failed", "state.device-empty", "page.device-detail"]) if (!deviceNodeIds.has(id)) failures.push(`T4 device flow node missing: ${id}`);
|
||||||
|
if (new Set(deviceFlow.nodes.map((node) => node.id)).size !== deviceFlow.nodes.length) failures.push("T4 device flow contains duplicate node IDs");
|
||||||
|
if (new Set(deviceFlow.edges.map((edge) => edge.id)).size !== deviceFlow.edges.length) failures.push("T4 device flow contains duplicate edge IDs");
|
||||||
|
const deviceComponents = new Set(["DeviceList", "DeviceCreate", "DeviceConfig", "DeviceConnecting", "DeviceConnected", "DeviceConnectionFailed", "DeviceEmpty", "DeviceDetail"]);
|
||||||
|
for (const node of deviceFlow.nodes.filter((item) => deviceNodeIds.has(item.id) && item.id.startsWith("page.device") || item.id.startsWith("state.device"))) if (!deviceComponents.has(node.component)) failures.push(`T4 node ${node.id} has no device component`);
|
||||||
|
const mockSource = fs.readFileSync(path.join(root, "src", "mocks", "device-management.ts"), "utf8");
|
||||||
|
for (const forbidden of ["Math.random", "fetch(", "localStorage"]) if (mockSource.includes(forbidden)) failures.push(`T4 mock must not use ${forbidden}`);
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(root, "snapshots", "device-management.manifest.json"), "utf8"));
|
||||||
|
if (manifest.status !== "pending-t5-browser-generation" || manifest.screenshots.length !== 0) failures.push("source screenshot manifest must defer real rendering without fake images");
|
||||||
|
const deviceMockTest = spawnSync(process.execPath, ["--experimental-strip-types", path.join(root, "tests", "device-management.test.ts")], { encoding: "utf8" });
|
||||||
|
if (deviceMockTest.status !== 0) failures.push(`T4 deterministic mock unit test failed: ${deviceMockTest.stderr || deviceMockTest.stdout}`);
|
||||||
|
if (failures.length) { console.error(failures.map((failure) => `TEST-FAIL ${failure}`).join("\n")); process.exit(1); }
|
||||||
|
console.log("offline validator contract checks passed (8 invalid fixtures, valid fixture, incremental layout)");
|
||||||
|
console.log("Studio/runtime contract checks passed (node kinds, registry edges, controls, fail-closed runtime)");
|
||||||
|
console.log("device flow and deterministic mock checks passed");
|
||||||
|
const playwrightBin = path.join(root, "node_modules", "@playwright", "test", "cli.js");
|
||||||
|
if (!fs.existsSync(playwrightBin)) { console.error(`TEST_DEPENDENCY_MISSING: Playwright is not installed at ${playwrightBin}`); process.exit(2); }
|
||||||
|
const e2e = spawnSync(process.execPath, [playwrightBin, "test"], { cwd: root, encoding: "utf8", stdio: "inherit", env: { ...process.env, PROTOTYPE_ARTIFACTS: process.env.PROTOTYPE_ARTIFACTS ?? ".prototype-artifacts" } });
|
||||||
|
if (e2e.error) { console.error(`TEST_ENVIRONMENT: unable to start Playwright: ${e2e.error.message}`); process.exit(2); }
|
||||||
|
if (e2e.status !== 0) { console.error(`TEST_PLAYWRIGHT_FAILED: Playwright exited with code ${e2e.status ?? 1}`); process.exit(e2e.status ?? 1); }
|
||||||
|
console.log("Playwright desktop/tablet/mobile interaction tests passed");
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Offline validator for the four prototype source files. */
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import registry from "../src/registry/registry.json" with { type: "json" };
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let dataDir = "prototype";
|
||||||
|
let jsonOutput = false;
|
||||||
|
let quiet = false;
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const arg = args[index];
|
||||||
|
if (arg === "--data-dir") dataDir = args[++index] ?? "";
|
||||||
|
else if (arg === "--json") jsonOutput = true;
|
||||||
|
else if (arg === "--quiet") quiet = true;
|
||||||
|
else if (arg === "--help" || arg === "-h") { console.log("Usage: node scripts/validate.mjs [--data-dir DIR] [--json] [--quiet]"); process.exit(0); }
|
||||||
|
else { console.error(`validate: unknown argument ${arg}`); process.exit(2); }
|
||||||
|
}
|
||||||
|
const root = path.resolve(process.cwd(), dataDir);
|
||||||
|
const errors = [];
|
||||||
|
const push = (file, id, code, message) => errors.push({ file, id, code, message });
|
||||||
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
const isPrimitive = (value) => value === null || ["string", "number", "boolean"].includes(typeof value);
|
||||||
|
const idPattern = /^[A-Za-z][A-Za-z0-9._:-]*$/;
|
||||||
|
const fieldPattern = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
||||||
|
const reservedFields = new Set(["__proto__", "prototype", "constructor"]);
|
||||||
|
const nodeKinds = new Set(["page", "dialog", "state"]);
|
||||||
|
const reviewStatuses = new Set(["draft", "in-review", "approved", "changes-requested"]);
|
||||||
|
const targetKinds = new Set(["node", "edge", "page"]);
|
||||||
|
const conditionOps = new Set(["eq", "neq", "in", "notIn", "exists", "truthy", "falsy", "and", "or", "not"]);
|
||||||
|
|
||||||
|
function readJson(fileName) {
|
||||||
|
const file = path.join(root, fileName);
|
||||||
|
try { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
||||||
|
catch (cause) {
|
||||||
|
const code = cause?.code === "ENOENT" ? "FILE_READ" : "JSON_PARSE";
|
||||||
|
push(fileName, fileName.replace(/\.json$/, ""), code, cause?.code === "ENOENT" ? `file does not exist: ${file}` : `invalid JSON: ${cause.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRequirements() {
|
||||||
|
const file = "requirements.md";
|
||||||
|
let value;
|
||||||
|
try { value = fs.readFileSync(path.join(root, file), "utf8"); }
|
||||||
|
catch (_cause) { push(file, "requirements", "FILE_READ", `file does not exist: ${path.join(root, file)}`); return; }
|
||||||
|
if (!value.trim()) { push(file, "requirements", "SCHEMA_INVALID", "requirements.md must be non-empty markdown"); return; }
|
||||||
|
for (const section of ["Goal", "Scenario", "Acceptance context"]) if (!new RegExp(`^##\\s+${section}\\s*$`, "mi").test(value)) push(file, `requirements.${section.toLowerCase().replaceAll(" ", "-")}`, "SCHEMA_INVALID", `missing required ## ${section} section`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCondition(value, file, id) {
|
||||||
|
if (!isObject(value) || typeof value.op !== "string" || !conditionOps.has(value.op)) { push(file, id, "INVALID_CONDITION", "condition must use a whitelisted structured operator"); return; }
|
||||||
|
const op = value.op;
|
||||||
|
if (["eq", "neq", "in", "notIn", "exists", "truthy", "falsy"].includes(op) && (typeof value.field !== "string" || !fieldPattern.test(value.field) || reservedFields.has(value.field))) push(file, id, "INVALID_CONDITION", "condition field must be a safe dotted path");
|
||||||
|
if (["eq", "neq"].includes(op) && !isPrimitive(value.value)) push(file, id, "INVALID_CONDITION", `${op} value must be a primitive`);
|
||||||
|
if (["in", "notIn"].includes(op) && (!Array.isArray(value.value) || !value.value.every(isPrimitive))) push(file, id, "INVALID_CONDITION", `${op} value must be an array of primitives`);
|
||||||
|
if (["and", "or"].includes(op)) {
|
||||||
|
if (!Array.isArray(value.conditions) || value.conditions.length === 0) push(file, id, "INVALID_CONDITION", `${op} requires a non-empty conditions array`);
|
||||||
|
else value.conditions.forEach((child, index) => validateCondition(child, file, `${id}.conditions[${index}]`));
|
||||||
|
}
|
||||||
|
if (op === "not") {
|
||||||
|
if (!("condition" in value)) push(file, id, "INVALID_CONDITION", "not requires a nested condition");
|
||||||
|
else validateCondition(value.condition, file, `${id}.condition`);
|
||||||
|
}
|
||||||
|
const allowed = op === "not" ? ["op", "condition"] : ["and", "or"].includes(op) ? ["op", "conditions"] : ["exists", "truthy", "falsy"].includes(op) ? ["op", "field"] : ["op", "field", "value"];
|
||||||
|
Object.keys(value).filter((key) => !allowed.includes(key)).forEach((key) => push(file, id, "INVALID_CONDITION", `unknown condition property ${key}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateId(ids, file, kind) {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [index, id] of ids.entries()) {
|
||||||
|
const stableId = typeof id === "string" ? id : `${kind}[${index}]`;
|
||||||
|
if (typeof id !== "string" || !idPattern.test(id)) push(file, stableId, "SCHEMA_INVALID", `${kind} contains an invalid stable ID`);
|
||||||
|
else if (seen.has(id)) push(file, id, "DUPLICATE_ID", `duplicate ${kind} ID ${id}`);
|
||||||
|
else seen.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFlow(flow) {
|
||||||
|
const file = "flow.json";
|
||||||
|
if (!isObject(flow) || flow.flowVersion !== 1 || typeof flow.startNodeId !== "string" || !Array.isArray(flow.nodes) || !Array.isArray(flow.edges)) { push(file, "flow", "SCHEMA_INVALID", "flow requires flowVersion 1, startNodeId, nodes and edges"); return { nodes: new Map(), edges: new Map() }; }
|
||||||
|
duplicateId(flow.nodes.map((node) => node?.id), file, "node");
|
||||||
|
duplicateId(flow.edges.map((edge) => edge?.id), file, "edge");
|
||||||
|
const nodes = new Map();
|
||||||
|
flow.nodes.forEach((node, index) => {
|
||||||
|
const id = typeof node?.id === "string" ? node.id : `nodes[${index}]`;
|
||||||
|
if (!isObject(node) || !idPattern.test(node.id ?? "") || !nodeKinds.has(node.kind) || typeof node.component !== "string" || typeof node.title !== "string") { push(file, id, "SCHEMA_INVALID", "node requires id, kind, component and title"); return; }
|
||||||
|
if (nodes.has(id)) return;
|
||||||
|
nodes.set(id, node);
|
||||||
|
if (!registry.components[node.component]) push(file, id, "UNKNOWN_COMPONENT", `component ${node.component} is not registered`);
|
||||||
|
else if (!registry.components[node.component].kinds.includes(node.kind)) push(file, id, "UNKNOWN_COMPONENT", `component ${node.component} cannot render node kind ${node.kind}`);
|
||||||
|
});
|
||||||
|
if (!nodes.has(flow.startNodeId)) push(file, flow.startNodeId, "INVALID_START", "startNodeId does not reference a node");
|
||||||
|
const edges = new Map();
|
||||||
|
flow.edges.forEach((edge, index) => {
|
||||||
|
const id = typeof edge?.id === "string" ? edge.id : `edges[${index}]`;
|
||||||
|
if (!isObject(edge) || !idPattern.test(edge.id ?? "") || typeof edge.source !== "string" || typeof edge.target !== "string" || typeof edge.actionId !== "string") { push(file, id, "SCHEMA_INVALID", "edge requires id, source, target and actionId"); return; }
|
||||||
|
if (edges.has(id)) return;
|
||||||
|
edges.set(id, edge);
|
||||||
|
if (!nodes.has(edge.source)) push(file, id, "DANGLING_REFERENCE", `edge source ${edge.source} does not exist`);
|
||||||
|
if (!nodes.has(edge.target)) push(file, id, "DANGLING_REFERENCE", `edge target ${edge.target} does not exist`);
|
||||||
|
const action = registry.actions[edge.actionId];
|
||||||
|
if (!action) push(file, edge.actionId, "UNKNOWN_ACTION", `action ${edge.actionId} is not registered`);
|
||||||
|
else if (nodes.has(edge.source) && !action.sourceKinds.includes(nodes.get(edge.source).kind)) push(file, edge.actionId, "UNKNOWN_ACTION", `action ${edge.actionId} is not allowed from ${nodes.get(edge.source).kind}`);
|
||||||
|
if ("when" in edge) validateCondition(edge.when, file, id);
|
||||||
|
});
|
||||||
|
if (nodes.has(flow.startNodeId)) {
|
||||||
|
const reachable = new Set([flow.startNodeId]);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) { changed = false; for (const edge of edges.values()) if (reachable.has(edge.source) && nodes.has(edge.target) && !reachable.has(edge.target)) { reachable.add(edge.target); changed = true; } }
|
||||||
|
for (const id of nodes.keys()) if (!reachable.has(id)) push(file, id, "UNREACHABLE_NODE", `node ${id} is not reachable from ${flow.startNodeId}`);
|
||||||
|
}
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLayout(layout, nodes) {
|
||||||
|
const file = "layout.json";
|
||||||
|
if (!isObject(layout) || layout.layoutVersion !== 1 || !isObject(layout.viewport) || !isObject(layout.nodes) || typeof layout.viewport.x !== "number" || !Number.isFinite(layout.viewport.x) || typeof layout.viewport.y !== "number" || !Number.isFinite(layout.viewport.y) || typeof layout.viewport.zoom !== "number" || !Number.isFinite(layout.viewport.zoom) || layout.viewport.zoom <= 0) { push(file, "layout", "SCHEMA_INVALID", "layout requires version 1, viewport and nodes"); return; }
|
||||||
|
for (const [id, position] of Object.entries(layout.nodes)) {
|
||||||
|
if (!nodes.has(id)) push(file, id, "DANGLING_REFERENCE", `layout node ${id} does not exist in flow`);
|
||||||
|
if (!isObject(position) || typeof position.x !== "number" || !Number.isFinite(position.x) || typeof position.y !== "number" || !Number.isFinite(position.y)) push(file, id, "SCHEMA_INVALID", `layout node ${id} requires finite x/y coordinates`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReview(review, nodes, edges) {
|
||||||
|
const file = "review.json";
|
||||||
|
if (!isObject(review) || review.reviewVersion !== 1 || !reviewStatuses.has(review.status) || !Array.isArray(review.comments)) { push(file, "review", "SCHEMA_INVALID", "review requires version 1, status and comments"); return; }
|
||||||
|
const comments = new Set();
|
||||||
|
review.comments.forEach((comment, index) => {
|
||||||
|
const id = typeof comment?.id === "string" ? comment.id : `comments[${index}]`;
|
||||||
|
if (!isObject(comment) || !idPattern.test(comment.id ?? "") || typeof comment.body !== "string" || !isObject(comment.target) || !targetKinds.has(comment.target.kind) || typeof comment.target.id !== "string") { push(file, id, "SCHEMA_INVALID", "comment requires id, body and a node/edge/page target"); return; }
|
||||||
|
if (comments.has(id)) push(file, id, "DUPLICATE_ID", `duplicate comment ID ${id}`); else comments.add(id);
|
||||||
|
const exists = comment.target.kind === "edge" ? edges.has(comment.target.id) : nodes.has(comment.target.id);
|
||||||
|
if (!exists) push(file, comment.target.id, "DANGLING_REFERENCE", `review target ${comment.target.id} does not exist in flow`);
|
||||||
|
if (comment.target.kind === "page" && nodes.has(comment.target.id) && nodes.get(comment.target.id).kind !== "page") push(file, comment.target.id, "DANGLING_REFERENCE", `review page target ${comment.target.id} is not a page node`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validateRequirements();
|
||||||
|
const flow = readJson("flow.json");
|
||||||
|
const layout = readJson("layout.json");
|
||||||
|
const review = readJson("review.json");
|
||||||
|
const graph = flow ? validateFlow(flow) : { nodes: new Map(), edges: new Map() };
|
||||||
|
if (layout) validateLayout(layout, graph.nodes);
|
||||||
|
if (review) validateReview(review, graph.nodes, graph.edges);
|
||||||
|
if (jsonOutput) console.log(JSON.stringify({ valid: errors.length === 0, errors }, null, 2));
|
||||||
|
else if (!quiet) {
|
||||||
|
if (!errors.length) console.log(`validate: OK (${root})`);
|
||||||
|
else for (const item of errors) console.error(`${item.file} :: ${item.id} :: ${item.code} :: ${item.message}`);
|
||||||
|
}
|
||||||
|
process.exitCode = errors.length ? 1 : 0;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export async function startViteServer(root, { host = "127.0.0.1", port = 4173 } = {}) {
|
||||||
|
const viteBin = path.join(root, "node_modules", "vite", "bin", "vite.js");
|
||||||
|
if (!fs.existsSync(viteBin)) {
|
||||||
|
const error = new Error(`DEV_DEPENDENCY_MISSING: Vite is not installed at ${viteBin}`);
|
||||||
|
error.code = "DEV_DEPENDENCY_MISSING";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const child = spawn(process.execPath, [viteBin, "--host", host, "--port", String(port), "--strictPort"], { cwd: root, env: { ...process.env, BROWSER: "none" }, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
let output = "";
|
||||||
|
child.stdout.on("data", (chunk) => { output += chunk.toString(); });
|
||||||
|
child.stderr.on("data", (chunk) => { output += chunk.toString(); });
|
||||||
|
const url = [`http`, `://`, host, `:`, port].join("");
|
||||||
|
try { await waitForHttp(url, child); }
|
||||||
|
catch (error) { await stopViteServer(child); error.message = `${error.message}\n${output.trim()}`.trim(); throw error; }
|
||||||
|
return { child, url, output: () => output };
|
||||||
|
}
|
||||||
|
export function stopViteServer(server) {
|
||||||
|
if (!server || server.exitCode !== null) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => { if (settled) return; settled = true; clearTimeout(forceKill); resolve(); };
|
||||||
|
const forceKill = setTimeout(() => { if (server.exitCode === null) server.kill("SIGKILL"); finish(); }, 5_000);
|
||||||
|
server.once("exit", finish);
|
||||||
|
try { if (!server.kill("SIGTERM")) finish(); } catch { finish(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function waitForHttp(url, child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deadline = Date.now() + 20_000;
|
||||||
|
const retry = () => { if (Date.now() >= deadline) { const error = new Error(`DEV_SERVER_TIMEOUT: Vite did not serve ${url}`); error.code = "DEV_SERVER_TIMEOUT"; reject(error); } else setTimeout(poll, 100); };
|
||||||
|
const poll = () => {
|
||||||
|
if (child.exitCode !== null) { const error = new Error(`DEV_SERVER_EXITED: Vite exited with code ${child.exitCode}`); error.code = "DEV_SERVER_EXITED"; reject(error); return; }
|
||||||
|
const request = http.get(url, (response) => { response.resume(); if ((response.statusCode ?? 500) < 500) resolve(); else retry(); });
|
||||||
|
request.on("error", retry); request.setTimeout(1_000, () => { request.destroy(); retry(); });
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Snapshot handoff
|
||||||
|
|
||||||
|
`device-management.manifest.json` is the source declaration for the device-management screenshot targets.
|
||||||
|
`npm run snapshot` generates actual rendered screenshots for each declared page and viewport, then writes
|
||||||
|
output paths, browser environment and `status: generated` to `.prototype-artifacts/snapshots/`. No image
|
||||||
|
placeholder is valid.
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"feature": "device-management",
|
||||||
|
"status": "pending-t5-browser-generation",
|
||||||
|
"source": "../../examples/device-management/screenshot-manifest.json",
|
||||||
|
"screenshots": [],
|
||||||
|
"note": "T4 declares real-render targets only. T5 owns browser generation; no PNG is claimed here."
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { Background, Controls, Handle, MiniMap, MarkerType, Position, ReactFlow, ReactFlowProvider, applyNodeChanges, type Edge, type Node, type NodeProps, type OnSelectionChangeParams, type Viewport } from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import flowSource from "../prototype/flow.json";
|
||||||
|
import layoutSource from "../prototype/layout.json";
|
||||||
|
import reviewSource from "../prototype/review.json";
|
||||||
|
import requirementsSource from "../prototype/requirements.md?raw";
|
||||||
|
import type { FlowDocument, FlowEdge, FlowNode, Condition } from "./contracts/flow";
|
||||||
|
import type { LayoutDocument } from "./contracts/layout";
|
||||||
|
import type { ReviewDocument, ReviewTarget } from "./contracts/review";
|
||||||
|
import { captureLayout, downloadText, layoutNodes, parseLayout, serializeLayout } from "./layout";
|
||||||
|
import { addReviewComment, exportReview, reviewTargetLabel, updateReviewStatus } from "./review";
|
||||||
|
import { createNavigationRuntime, type DispatchResult } from "./runtime";
|
||||||
|
import { DeviceManagementPreview, isDevicePreview } from "./examples/device-management/DeviceManagementPreview";
|
||||||
|
import { createDeviceMock, reduceDeviceMock, type DeviceDraft, type DeviceScenario } from "./mocks/device-management";
|
||||||
|
|
||||||
|
const flow = flowSource as FlowDocument;
|
||||||
|
const initialLayout = layoutSource as LayoutDocument;
|
||||||
|
const initialReview = reviewSource as ReviewDocument;
|
||||||
|
const kindLabel: Record<FlowNode["kind"], string> = { page: "PAGE", dialog: "DIALOG", state: "STATE" };
|
||||||
|
const kindColor: Record<FlowNode["kind"], string> = { page: "#74c0fc", dialog: "#f783ac", state: "#63e6be" };
|
||||||
|
|
||||||
|
function formatCondition(condition: Condition): string {
|
||||||
|
switch (condition.op) {
|
||||||
|
case "and": return condition.conditions.map(formatCondition).join(" AND ");
|
||||||
|
case "or": return condition.conditions.map(formatCondition).join(" OR ");
|
||||||
|
case "not": return `NOT (${formatCondition(condition.condition)})`;
|
||||||
|
case "exists": return `${condition.field} exists`;
|
||||||
|
case "truthy": return `${condition.field} is truthy`;
|
||||||
|
case "falsy": return `${condition.field} is falsy`;
|
||||||
|
case "in":
|
||||||
|
case "notIn": return `${condition.field} ${condition.op} [${condition.value.map(String).join(", ")}]`;
|
||||||
|
default: return `${condition.field} ${condition.op} ${String(condition.value)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrototypeNode({ data, selected }: NodeProps<Node<FlowNode>>) {
|
||||||
|
return <article className={`prototype-node kind-${data.kind}${selected ? " is-selected" : ""}`} data-testid={`flow-node-${data.id}`}>
|
||||||
|
<Handle type="target" position={Position.Left} />
|
||||||
|
<div className="node-kind" style={{ color: kindColor[data.kind] }}>{kindLabel[data.kind]}</div>
|
||||||
|
<div className="node-title">{data.title}</div><code className="node-id">{data.id}</code>
|
||||||
|
{data.thumbnail ? <img className="node-thumbnail" src={data.thumbnail} alt={`${data.title} thumbnail`} /> : <div className="node-thumbnail placeholder-thumbnail">No thumbnail asset</div>}
|
||||||
|
<div className="node-component">{data.component}</div><Handle type="source" position={Position.Right} />
|
||||||
|
</article>;
|
||||||
|
}
|
||||||
|
const nodeTypes = { prototype: PrototypeNode };
|
||||||
|
function selectedTarget(nodeId: string | null, edgeId: string | null): ReviewTarget | null { return edgeId ? { kind: "edge", id: edgeId } : nodeId ? { kind: "node", id: nodeId } : null; }
|
||||||
|
|
||||||
|
function StudioApp() {
|
||||||
|
const runtime = useMemo(() => createNavigationRuntime(flow), []);
|
||||||
|
const [nodes, setNodes] = useState<Node<FlowNode>[]>(() => layoutNodes(flow, initialLayout));
|
||||||
|
const [viewport, setViewport] = useState<Viewport>(initialLayout.viewport);
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(flow.startNodeId);
|
||||||
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
|
const [currentNodeId, setCurrentNodeId] = useState(flow.startNodeId);
|
||||||
|
const [runtimeMessage, setRuntimeMessage] = useState("Ready — choose an Action from the preview.");
|
||||||
|
const [runtimeError, setRuntimeError] = useState(false);
|
||||||
|
const [showEmpty, setShowEmpty] = useState(false);
|
||||||
|
const [deviceScenario, setDeviceScenario] = useState<DeviceScenario>("normal");
|
||||||
|
const [deviceMock, setDeviceMock] = useState(() => createDeviceMock("normal"));
|
||||||
|
const [deviceDraft, setDeviceDraft] = useState<DeviceDraft>(() => createDeviceMock("normal").draft);
|
||||||
|
const [deviceFormErrors, setDeviceFormErrors] = useState<string[]>([]);
|
||||||
|
const [review, setReview] = useState<ReviewDocument>(initialReview);
|
||||||
|
const [commentBody, setCommentBody] = useState("");
|
||||||
|
const [reviewStatus, setReviewStatus] = useState<ReviewDocument["status"]>(initialReview.status);
|
||||||
|
const layoutInput = useRef<HTMLInputElement>(null);
|
||||||
|
const currentNode = flow.nodes.find((node) => node.id === currentNodeId);
|
||||||
|
const selectedNode = flow.nodes.find((node) => node.id === selectedNodeId);
|
||||||
|
const selectedEdge = flow.edges.find((edge) => edge.id === selectedEdgeId);
|
||||||
|
const outgoing = currentNode ? runtime.outgoing(currentNode.id) : [];
|
||||||
|
const edges = useMemo<Edge<FlowEdge>[]>(() => flow.edges.map((edge: FlowEdge) => ({
|
||||||
|
id: edge.id, source: edge.source, target: edge.target, type: "smoothstep",
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, color: "#86a8ca" },
|
||||||
|
label: `${edge.actionId}${edge.when ? ` · when ${formatCondition(edge.when)}` : ""}`,
|
||||||
|
data: edge, labelStyle: { fill: "#c5ddf7", fontSize: 10, fontWeight: 650 },
|
||||||
|
labelBgStyle: { fill: "#17263b", fillOpacity: 0.96, stroke: "#365274" }, labelBgPadding: [6, 3] as [number, number],
|
||||||
|
})), []);
|
||||||
|
const onSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: OnSelectionChangeParams) => { setSelectedNodeId(selectedNodes[0]?.id ?? null); setSelectedEdgeId(selectedEdges[0]?.id ?? null); }, []);
|
||||||
|
const dispatchAction = (actionId: string, input: Partial<DeviceDraft> = {}) => {
|
||||||
|
let context: Record<string, unknown> = { showEmpty, deviceScenario, deviceExists: deviceMock.devices.length > 0 };
|
||||||
|
if (actionId.startsWith("device.")) {
|
||||||
|
const mockResult = reduceDeviceMock(deviceMock, actionId, input);
|
||||||
|
setDeviceMock(mockResult.state);
|
||||||
|
setDeviceDraft(mockResult.state.draft);
|
||||||
|
setDeviceFormErrors(mockResult.errors);
|
||||||
|
if (!mockResult.ok) { setRuntimeError(true); setRuntimeMessage(`FORM_REQUIRED: ${mockResult.errors.join(";")}`); return; }
|
||||||
|
context = { ...context, ...mockResult.context, deviceExists: mockResult.state.devices.length > 0 };
|
||||||
|
}
|
||||||
|
const result: DispatchResult = runtime.dispatch(currentNodeId, actionId, context);
|
||||||
|
if (result.ok) { setCurrentNodeId(result.nodeId); setSelectedNodeId(result.nodeId); setSelectedEdgeId(result.edgeId); setRuntimeError(false); setRuntimeMessage(`Navigated via ${result.actionId} → ${result.nodeId}`); }
|
||||||
|
else { setRuntimeError(true); setRuntimeMessage(`${result.error.code}: ${result.error.message}`); }
|
||||||
|
};
|
||||||
|
const dispatch = (edge: FlowEdge) => dispatchAction(edge.actionId);
|
||||||
|
const changeDeviceScenario = (scenario: DeviceScenario) => { const nextMock = createDeviceMock(scenario); setDeviceScenario(scenario); setDeviceMock(nextMock); setDeviceDraft(nextMock.draft); setDeviceFormErrors([]); setRuntimeError(false); setRuntimeMessage(`Device mock scenario: ${scenario}`); };
|
||||||
|
const updateDeviceDraft = (patch: Partial<DeviceDraft>) => { setDeviceDraft((current) => ({ ...current, ...patch })); setDeviceFormErrors([]); };
|
||||||
|
const saveLayout = () => { downloadText("layout.json", serializeLayout(captureLayout(nodes, viewport))); setRuntimeError(false); setRuntimeMessage("layout.json exported from current node coordinates and viewport."); };
|
||||||
|
const restoreLayout = async (file: File) => { try { const next = parseLayout(await file.text(), flow); setNodes(layoutNodes(flow, next)); setViewport(next.viewport); setRuntimeError(false); setRuntimeMessage("layout.json restored; coordinates are keyed by stable node ID."); } catch (error) { setRuntimeError(true); setRuntimeMessage(error instanceof Error ? error.message : "LAYOUT_INVALID: unable to restore layout.json"); } };
|
||||||
|
const addComment = () => {
|
||||||
|
const target = selectedTarget(selectedNodeId, selectedEdgeId); if (!target || !commentBody.trim()) return;
|
||||||
|
try { setReview((current) => addReviewComment(current, flow, { id: `comment.${current.comments.length + 1}`, body: commentBody.trim(), target, status: "open", author: "Studio" })); setCommentBody(""); setRuntimeError(false); setRuntimeMessage(`Review comment attached to ${reviewTargetLabel(target)}.`); }
|
||||||
|
catch (error) { setRuntimeError(true); setRuntimeMessage(error instanceof Error ? error.message : "DANGLING_TARGET: review target rejected"); }
|
||||||
|
};
|
||||||
|
const exportReviewFile = () => { try { const next = updateReviewStatus(review, reviewStatus); setReview(next); downloadText("review.json", exportReview(next, flow)); setRuntimeError(false); setRuntimeMessage("review.json exported with stable node/edge targets."); } catch (error) { setRuntimeError(true); setRuntimeMessage(error instanceof Error ? error.message : "DANGLING_TARGET: review export rejected"); } };
|
||||||
|
const previewNode = selectedNode ?? currentNode;
|
||||||
|
return <main className="studio-shell">
|
||||||
|
<header className="studio-header"><div><p className="eyebrow">PRODUCT PROTOTYPE / T3</p><h1>Prototype Studio</h1></div><div className="header-actions"><span className="status-pill">offline · flow-driven</span><button onClick={saveLayout}>Save layout.json</button><button onClick={() => layoutInput.current?.click()}>Restore layout.json</button><input ref={layoutInput} className="visually-hidden" type="file" accept="application/json,.json" onChange={(event) => { const file = event.target.files?.[0]; if (file) void restoreLayout(file); event.target.value = ""; }} /></div></header>
|
||||||
|
<section className="studio-grid" aria-label="Prototype Studio">
|
||||||
|
<aside className="source-panel"><h2>Source data</h2><p>One data entry: flow/layout/review are loaded from versioned files. Requirements remain human-readable context.</p><ul><li><code>requirements.md</code></li><li><code>flow.json</code></li><li><code>layout.json</code></li><li><code>review.json</code></li></ul><div className="source-summary"><span>{flow.nodes.length} nodes</span><span>{flow.edges.length} transitions</span><span>{review.comments.length} review notes</span></div><p className="requirements-preview">{requirementsSource.split("\n").find((line) => line.startsWith("#")) ?? "Prototype requirements"}</p><label className="mock-selector">Device mock scenario<select data-testid="device-scenario" value={deviceScenario} onChange={(event) => changeDeviceScenario(event.target.value as DeviceScenario)}><option value="normal">正常 / 连接成功</option><option value="empty">空列表</option><option value="retry-success">连接失败 → 重试成功</option><option value="retry-failure">连接失败 → 再次失败</option></select></label><h2>Review</h2><label>Status<select value={reviewStatus} onChange={(event) => setReviewStatus(event.target.value as ReviewDocument["status"])}><option value="draft">draft</option><option value="in-review">in-review</option><option value="approved">approved</option><option value="changes-requested">changes-requested</option></select></label><label className="comment-label">Comment target <span>{selectedTarget(selectedNodeId, selectedEdgeId) ? reviewTargetLabel(selectedTarget(selectedNodeId, selectedEdgeId)!) : "select a node or edge"}</span></label><textarea value={commentBody} onChange={(event) => setCommentBody(event.target.value)} placeholder="Add a review note…" rows={3} /><div className="button-row"><button onClick={addComment} disabled={!commentBody.trim() || !selectedTarget(selectedNodeId, selectedEdgeId)}>Add comment</button><button onClick={exportReviewFile}>Export review.json</button></div></aside>
|
||||||
|
<div className="canvas-panel" aria-label="React Flow canvas"><div className="canvas-notice"><strong>Flow canvas</strong><span>Drag nodes to stage the graph; Save layout.json when ready.</span></div><ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} viewport={viewport} onlyRenderVisibleElements={false} onInit={() => setNodes((current) => [...current])} onNodesChange={(changes) => setNodes((current) => applyNodeChanges(changes, current))} onMoveEnd={(_, nextViewport) => setViewport(nextViewport)} onSelectionChange={onSelectionChange} onNodeClick={(_, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null); setCurrentNodeId(node.id); setRuntimeError(false); setRuntimeMessage(`Previewing ${node.id}.`); }} onEdgeClick={(_, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null); }} minZoom={0.2} maxZoom={2.4} proOptions={{ hideAttribution: true }}><Background color="#273c55" gap={24} size={1} /><MiniMap nodeColor={(node) => kindColor[(node.data as FlowNode)?.kind] ?? "#7890aa"} pannable zoomable /><Controls showInteractive={false} /></ReactFlow></div>
|
||||||
|
<aside className="preview-panel"><h2>Preview runtime</h2><div className="runtime-status" role="status" data-testid="runtime-status" data-error={runtimeError}>{runtimeMessage}</div>{previewNode ? <>{isDevicePreview(previewNode) ? <DeviceManagementPreview node={previewNode} outgoing={outgoing} mock={deviceMock} draft={deviceDraft} errors={deviceFormErrors} onDraftChange={updateDeviceDraft} onAction={dispatchAction} /> : <><div className="preview-card"><div className="node-kind" style={{ color: kindColor[previewNode.kind] }}>{kindLabel[previewNode.kind]} PREVIEW</div><h3>{previewNode.title}</h3><code>{previewNode.id}</code><p>{previewNode.description ?? "No description supplied."}</p><div className="preview-placeholder">{previewNode.thumbnail ? <img src={previewNode.thumbnail} alt={`${previewNode.title} preview`} /> : "Preview placeholder — no thumbnail asset"}</div></div><div className="runtime-actions"><h3>Actions from current node</h3>{outgoing.length ? outgoing.map((edge) => <button key={edge.id} onClick={() => dispatch(edge)} data-testid={`dispatch-${edge.actionId}`}><span>{edge.label ?? edge.actionId}</span><small>{edge.actionId}{edge.when ? ` · ${formatCondition(edge.when)}` : ""}</small></button>) : <p className="muted">No outgoing actions.</p>}<label className="checkbox-label"><input type="checkbox" checked={showEmpty} onChange={(event) => setShowEmpty(event.target.checked)} /> showEmpty context = true</label></div></>}</> : <p className="muted">Select a node to preview it.</p>}{selectedNode ? <div className="inspector"><h2>Selected node</h2><p><strong>{selectedNode.id}</strong><br />kind: {selectedNode.kind}<br />component: {selectedNode.component}</p></div> : null}{selectedEdge ? <div className="inspector"><h2>Selected edge</h2><p><strong>{selectedEdge.id}</strong><br />Action: {selectedEdge.actionId}<br />condition: {selectedEdge.when ? formatCondition(selectedEdge.when) : "none"}</p></div> : null}</aside>
|
||||||
|
</section>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() { return <ReactFlowProvider><StudioApp /></ReactFlowProvider>; }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Contracts
|
||||||
|
|
||||||
|
此目录定义 requirements、flow、layout、review 的 TypeScript 类型。运行时数据仍位于
|
||||||
|
`prototype/`,不要在此复制第二份 JSON;schema validator 负责共享的结构、引用和条件检查。
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export type NodeKind = "page" | "dialog" | "state";
|
||||||
|
export type Primitive = string | number | boolean | null;
|
||||||
|
|
||||||
|
export interface FlowNode {
|
||||||
|
[key: string]: unknown;
|
||||||
|
id: string;
|
||||||
|
kind: NodeKind;
|
||||||
|
component: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
/** Optional path to an actual preview asset. Missing assets render a visible placeholder. */
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Condition =
|
||||||
|
| { op: "eq" | "neq"; field: string; value: Primitive }
|
||||||
|
| { op: "in" | "notIn"; field: string; value: Primitive[] }
|
||||||
|
| { op: "exists" | "truthy" | "falsy"; field: string }
|
||||||
|
| { op: "and" | "or"; conditions: Condition[] }
|
||||||
|
| { op: "not"; condition: Condition };
|
||||||
|
|
||||||
|
export interface FlowEdge {
|
||||||
|
[key: string]: unknown;
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
actionId: string;
|
||||||
|
label?: string;
|
||||||
|
when?: Condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowDocument {
|
||||||
|
flowVersion: 1;
|
||||||
|
startNodeId: string;
|
||||||
|
nodes: FlowNode[];
|
||||||
|
edges: FlowEdge[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./requirements";
|
||||||
|
export * from "./flow";
|
||||||
|
export * from "./layout";
|
||||||
|
export * from "./review";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface Position { x: number; y: number; }
|
||||||
|
export interface LayoutNode extends Position {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
export interface Viewport extends Position { zoom: number; }
|
||||||
|
export interface LayoutDocument {
|
||||||
|
layoutVersion: 1;
|
||||||
|
viewport: Viewport;
|
||||||
|
nodes: Record<string, LayoutNode>;
|
||||||
|
groups?: Record<string, { nodeIds: string[]; label?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A patch only carries changed stable IDs; removeNodeIds explicitly deletes IDs. */
|
||||||
|
export interface LayoutPatch {
|
||||||
|
layoutVersion: 1;
|
||||||
|
viewport?: Viewport;
|
||||||
|
nodes?: Record<string, LayoutNode & { expected?: Position }>;
|
||||||
|
removeNodeIds?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** The markdown requirements source is intentionally kept human-readable. */
|
||||||
|
export interface RequirementsDocument {
|
||||||
|
file: string;
|
||||||
|
goal: string;
|
||||||
|
scenario: string;
|
||||||
|
acceptanceContext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REQUIREMENTS_SECTIONS = ["Goal", "Scenario", "Acceptance context"] as const;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type ReviewStatus = "draft" | "in-review" | "approved" | "changes-requested";
|
||||||
|
export type ReviewTargetKind = "node" | "edge" | "page";
|
||||||
|
|
||||||
|
export interface ReviewTarget {
|
||||||
|
kind: ReviewTargetKind;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewComment {
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
author?: string;
|
||||||
|
status?: "open" | "resolved";
|
||||||
|
target: ReviewTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewDocument {
|
||||||
|
reviewVersion: 1;
|
||||||
|
status: ReviewStatus;
|
||||||
|
comments: ReviewComment[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Data boundary
|
||||||
|
|
||||||
|
数据入口按稳定 ID 读取并组合 `prototype/` 的四类源数据;layout/review 的读写和增量边界分别
|
||||||
|
由 `src/layout/`、`src/review/` 与 `fixtures/incremental/` 的离线检查覆盖。不要从浏览器状态
|
||||||
|
或 localStorage 补建第二份数据。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { FlowDocument } from "../contracts/flow";
|
||||||
|
import type { LayoutDocument, LayoutPatch } from "../contracts/layout";
|
||||||
|
import type { ReviewDocument } from "../contracts/review";
|
||||||
|
import { mergeLayouts } from "../schema/validator";
|
||||||
|
|
||||||
|
export interface PrototypeData {
|
||||||
|
requirements: string;
|
||||||
|
flow: FlowDocument;
|
||||||
|
layout: LayoutDocument;
|
||||||
|
review: ReviewDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeLayoutPatch(base: LayoutDocument, patch: LayoutPatch, activeNodeIds: Iterable<string>): LayoutDocument {
|
||||||
|
return mergeLayouts(base, patch, new Set(activeNodeIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNode(data: PrototypeData, nodeId: string) {
|
||||||
|
return data.flow.nodes.find((node) => node.id === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEdge(data: PrototypeData, edgeId: string) {
|
||||||
|
return data.flow.edges.find((edge) => edge.id === edgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeIds(data: PrototypeData): Set<string> {
|
||||||
|
return new Set(data.flow.nodes.map((node) => node.id));
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { FlowEdge, FlowNode } from "../../contracts/flow";
|
||||||
|
import type { DeviceDraft, DeviceMockState } from "../../mocks/device-management";
|
||||||
|
|
||||||
|
export interface DeviceManagementPreviewProps {
|
||||||
|
node: FlowNode;
|
||||||
|
outgoing: FlowEdge[];
|
||||||
|
mock: DeviceMockState;
|
||||||
|
draft: DeviceDraft;
|
||||||
|
errors: string[];
|
||||||
|
onDraftChange: (patch: Partial<DeviceDraft>) => void;
|
||||||
|
onAction: (actionId: string, input?: Partial<DeviceDraft>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge = (outgoing: FlowEdge[], actionId: string) => outgoing.find((item) => item.actionId === actionId);
|
||||||
|
|
||||||
|
function ActionButton({ outgoing, actionId, onAction, children, secondary = false }: { outgoing: FlowEdge[]; actionId: string; onAction: DeviceManagementPreviewProps["onAction"]; children: React.ReactNode; secondary?: boolean }) {
|
||||||
|
const transition = edge(outgoing, actionId);
|
||||||
|
if (!transition) return null;
|
||||||
|
return <button className={secondary ? "device-action secondary" : "device-action"} data-testid={`device-action-${actionId}`} onClick={() => onAction(actionId)}>{children}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceList({ outgoing, mock, onAction }: DeviceManagementPreviewProps) {
|
||||||
|
return <section className="device-page" data-testid="device-page-list">
|
||||||
|
<div className="device-breadcrumb">设备中心 <span>/</span> 设备管理</div>
|
||||||
|
<div className="device-page-heading"><div><p className="device-overline">DEVICE MANAGEMENT</p><h3>设备管理</h3><p className="device-subtitle">集中管理园区网关与传感器,实时掌握连接状态。</p></div><ActionButton outgoing={outgoing} actionId="device.create" onAction={onAction}>+ 新建设备</ActionButton></div>
|
||||||
|
<div className="device-toolbar"><div className="device-search">⌕ <span>搜索设备名称、地址或位置</span></div><span className="device-count">{mock.devices.length} 台设备</span></div>
|
||||||
|
{mock.devices.length ? <div className="device-table" role="table"><div className="device-table-row device-table-head" role="row"><span>设备</span><span>类型</span><span>连接地址</span><span>位置</span><span>状态</span><span /></div>{mock.devices.map((device) => <div className="device-table-row" role="row" key={device.id}><span className="device-name"><span className="device-icon">◈</span><span><strong>{device.name}</strong><small data-testid={`device-id-${device.id}`}>{device.id}</small></span></span><span>{device.type === "gateway" ? "边缘网关" : "环境传感器"}</span><span className="mono">{device.host}:{device.port}</span><span>{device.location || "未设置"}</span><span><b className="status-dot" />在线</span><button className="table-link" data-testid="device-action-device.open-detail" onClick={() => onAction("device.open-detail")}>查看详情</button></div>)}</div> : <div className="device-empty-inline"><div className="empty-art">◌</div><h4>还没有设备</h4><p>新建设备后,连接状态和运行信息会显示在这里。</p><ActionButton outgoing={outgoing} actionId="device.create" onAction={onAction}>新建设备</ActionButton></div>}
|
||||||
|
<div className="device-list-footer"><span>同步时间:刚刚</span><ActionButton outgoing={outgoing} actionId="device.show-empty" onAction={onAction} secondary>预览空列表状态</ActionButton></div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceForm({ node, outgoing, mock, draft, errors, onDraftChange, onAction }: DeviceManagementPreviewProps) {
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const isCreate = node.component === "DeviceCreate";
|
||||||
|
return <section className="device-page" data-testid={`device-page-${isCreate ? "create" : "config"}`}>
|
||||||
|
<div className="device-breadcrumb">设备管理 <span>/</span> {isCreate ? "新建设备" : "连接配置"}</div>
|
||||||
|
<div className="device-page-heading compact"><div><p className="device-overline">{isCreate ? "NEW DEVICE" : "CONNECTION SETUP"}</p><h3>{isCreate ? "新建设备" : "配置连接参数"}</h3><p className="device-subtitle">{isCreate ? "填写基础信息,创建一个离线可验证的设备连接。" : "确认连接参数后保存,系统会使用确定性 mock 完成连接演示。"}</p></div><span className="step-indicator">{isCreate ? "01" : "02"} <i /> 03</span></div>
|
||||||
|
<div className="device-form-card"><div className="device-form-section"><h4>基础信息</h4><p>设备标识用于在列表和详情页中追踪同一实体。</p><label>设备名称 <em>*</em><input data-testid="device-name" value={draft.name} onChange={(event) => onDraftChange({ name: event.target.value })} placeholder="例如:东区环境网关" /></label><label>设备类型 <select data-testid="device-type" value={draft.type} onChange={(event) => onDraftChange({ type: event.target.value as DeviceDraft["type"] })}><option value="gateway">边缘网关</option><option value="sensor">环境传感器</option></select></label><label>所属位置<input data-testid="device-location" value={draft.location} onChange={(event) => onDraftChange({ location: event.target.value })} placeholder="例如:研发楼 / 3F" /></label></div><div className="device-form-section"><h4>连接参数</h4><p>使用本地 mock 地址,不会访问真实后端或设备。</p><label>主机地址 <em>*</em><input data-testid="device-host" value={draft.host} onChange={(event) => onDraftChange({ host: event.target.value })} placeholder="10.24.8.20" /></label><label>端口 <em>*</em><input data-testid="device-port" value={draft.port} onChange={(event) => onDraftChange({ port: event.target.value })} placeholder="1883" /></label><button type="button" className="advanced-toggle" onClick={() => setShowAdvanced((value) => !value)}>{showAdvanced ? "收起高级参数" : "展开高级参数"} ⌄</button>{showAdvanced ? <div className="advanced-note">TLS、心跳和重连策略在生产环境中由平台配置,本原型只展示默认值。</div> : null}</div></div>
|
||||||
|
{errors.length ? <div className="form-errors" role="alert" data-testid="device-form-errors"><strong>请补充必填信息</strong>{errors.map((error) => <span key={error}>• {error}</span>)}</div> : null}
|
||||||
|
{mock.saveMessage && !errors.length ? <div className="form-success" role="status">✓ {mock.saveMessage}</div> : null}
|
||||||
|
<div className="device-form-actions"><ActionButton outgoing={outgoing} actionId={isCreate ? "device.back-to-list" : "device.back-to-create"} onAction={onAction} secondary>{isCreate ? "取消" : "← 返回上一步"}</ActionButton>{isCreate ? <ActionButton outgoing={outgoing} actionId="device.continue-to-config" onAction={() => onAction("device.continue-to-config", draft)}>继续配置 →</ActionButton> : <ActionButton outgoing={outgoing} actionId="device.save-config" onAction={() => onAction("device.save-config", draft)}>保存并连接 →</ActionButton>}</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceConnecting({ outgoing, mock, onAction }: DeviceManagementPreviewProps) {
|
||||||
|
return <section className="device-page device-state-page" data-testid="device-page-connecting"><div className="state-orbit connecting-orbit"><span>◌</span></div><p className="device-overline">CONNECTING</p><h3>正在连接设备</h3><p className="device-subtitle">正在验证 {mock.draft.name || "新设备"} 的连接参数,请稍候。</p>{mock.saveMessage ? <div className="form-success" role="status">✓ {mock.saveMessage}</div> : null}<div className="connection-steps"><span className="done">✓ 参数校验</span><span className="active">◌ 建立连接</span><span>○ 读取设备信息</span></div><ActionButton outgoing={outgoing} actionId="device.resolve-connection" onAction={onAction}>运行连接 mock</ActionButton></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceConnected({ outgoing, mock, onAction }: DeviceManagementPreviewProps) {
|
||||||
|
return <section className="device-page device-state-page" data-testid="device-page-connected"><div className="state-orbit success-orbit">✓</div><p className="device-overline success-text">CONNECTED</p><h3>设备连接成功</h3><p className="device-subtitle">{mock.draft.name || "新设备"} 已加入设备列表,稳定 ID 为 <code>device.mock-new</code>。</p><div className="success-metrics"><span><b>在线</b><small>连接状态</small></span><span><b>12 ms</b><small>响应延迟</small></span><span><b>刚刚</b><small>最后同步</small></span></div><ActionButton outgoing={outgoing} actionId="device.open-detail" onAction={onAction}>查看设备详情 →</ActionButton></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceConnectionFailed({ outgoing, mock, onAction }: DeviceManagementPreviewProps) {
|
||||||
|
return <section className="device-page device-state-page" data-testid="device-page-failed"><div className="state-orbit failed-orbit">!</div><p className="device-overline danger-text">CONNECTION FAILED</p><h3>连接设备失败</h3><p className="device-subtitle">无法连接 {mock.draft.name || "新设备"},但设备草稿已保留,可以安全重试。</p><div className="failure-reason"><strong>失败原因</strong><span>{mock.connectionReason ?? "连接超时:设备未在 5 秒内响应"}</span><small>第 {mock.connectionAttempt} 次尝试 · 确定性 mock 场景:{mock.scenario}</small></div><div className="device-form-actions"><ActionButton outgoing={outgoing} actionId="device.back-to-list" onAction={onAction} secondary>返回列表</ActionButton><ActionButton outgoing={outgoing} actionId="device.retry-connection" onAction={onAction}>重试连接 ↻</ActionButton></div></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceEmpty({ outgoing, onAction }: DeviceManagementPreviewProps) {
|
||||||
|
return <section className="device-page device-state-page" data-testid="device-page-empty"><div className="state-orbit empty-orbit">+</div><p className="device-overline">EMPTY STATE</p><h3>设备列表为空</h3><p className="device-subtitle">当前 mock 场景没有已注册设备。创建第一台设备开始体验。</p><ActionButton outgoing={outgoing} actionId="device.create" onAction={onAction}>新建设备 →</ActionButton></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceDetail({ outgoing, mock, onAction }: DeviceManagementPreviewProps) {
|
||||||
|
const device = mock.devices.find((item) => item.id === "device.mock-new") ?? mock.devices[0];
|
||||||
|
return <section className="device-page" data-testid="device-page-detail"><div className="device-breadcrumb">设备管理 <span>/</span> 设备详情</div><div className="device-detail-hero"><span className="device-icon large">◈</span><div><p className="device-overline">DEVICE DETAIL</p><h3>{device?.name ?? mock.draft.name ?? "新设备"}</h3><p className="mono">{device?.id ?? "device.mock-new"}</p></div><span className="online-badge"><b />在线</span></div><div className="detail-grid"><div><span>设备类型</span><strong>{device?.type === "sensor" ? "环境传感器" : "边缘网关"}</strong></div><div><span>连接地址</span><strong className="mono">{device ? `${device.host}:${device.port}` : `${mock.draft.host}:${mock.draft.port}`}</strong></div><div><span>所属位置</span><strong>{device?.location || mock.draft.location || "未设置"}</strong></div><div><span>最后同步</span><strong>刚刚</strong></div></div><div className="device-detail-note"><span>✓</span><p><strong>确定性示例数据</strong><br />本详情由离线 mock 生成,不会连接真实设备。</p></div><ActionButton outgoing={outgoing} actionId="device.back-to-list" onAction={onAction} secondary>← 返回设备列表</ActionButton></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceManagementPreview(props: DeviceManagementPreviewProps) {
|
||||||
|
switch (props.node.component) {
|
||||||
|
case "DeviceList": return <DeviceList {...props} />;
|
||||||
|
case "DeviceCreate": return <DeviceForm {...props} />;
|
||||||
|
case "DeviceConfig": return <DeviceForm {...props} />;
|
||||||
|
case "DeviceConnecting": return <DeviceConnecting {...props} />;
|
||||||
|
case "DeviceConnected": return <DeviceConnected {...props} />;
|
||||||
|
case "DeviceConnectionFailed": return <DeviceConnectionFailed {...props} />;
|
||||||
|
case "DeviceEmpty": return <DeviceEmpty {...props} />;
|
||||||
|
case "DeviceDetail": return <DeviceDetail {...props} />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDevicePreview(node: FlowNode | undefined): boolean {
|
||||||
|
return Boolean(node && ["DeviceList", "DeviceCreate", "DeviceConfig", "DeviceConnecting", "DeviceConnected", "DeviceConnectionFailed", "DeviceEmpty", "DeviceDetail"].includes(node.component));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Node as ReactFlowNode } from "@xyflow/react";
|
||||||
|
import type { FlowDocument } from "../contracts/flow";
|
||||||
|
import type { LayoutDocument, LayoutNode, Viewport } from "../contracts/layout";
|
||||||
|
import { validateLayout } from "../schema/validator";
|
||||||
|
|
||||||
|
export interface LayoutRoundTrip {
|
||||||
|
layout: LayoutDocument;
|
||||||
|
errors: ReturnType<typeof validateLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutNodes(flow: FlowDocument, layout: LayoutDocument): ReactFlowNode<import("../contracts/flow").FlowNode>[] {
|
||||||
|
const layoutErrors = validateLayout(layout, new Set(flow.nodes.map((node) => node.id)));
|
||||||
|
if (layoutErrors.length) throw new Error(layoutErrors.map((item) => `${item.code}:${item.id}:${item.message}`).join("\n"));
|
||||||
|
return flow.nodes.map((node, index) => {
|
||||||
|
const position = layout.nodes[node.id] ?? { x: 72 + (index % 3) * 300, y: 72 + Math.floor(index / 3) * 190 };
|
||||||
|
return { id: node.id, type: "prototype", position: { x: position.x, y: position.y }, data: node };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureLayout(nodes: Array<Pick<ReactFlowNode, "id" | "position" | "width" | "height">>, viewport: Viewport): LayoutDocument {
|
||||||
|
const result: Record<string, LayoutNode> = {};
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
result[node.id] = {
|
||||||
|
x: node.position.x,
|
||||||
|
y: node.position.y,
|
||||||
|
...(typeof node.width === "number" ? { width: node.width } : {}),
|
||||||
|
...(typeof node.height === "number" ? { height: node.height } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { layoutVersion: 1, viewport, nodes: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeLayout(layout: LayoutDocument): string {
|
||||||
|
return `${JSON.stringify(layout, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLayout(json: string, flow: FlowDocument): LayoutDocument {
|
||||||
|
let value: unknown;
|
||||||
|
try { value = JSON.parse(json); } catch (cause) { throw new Error(`LAYOUT_JSON_PARSE:${cause instanceof Error ? cause.message : "invalid JSON"}`); }
|
||||||
|
const errors = validateLayout(value, new Set(flow.nodes.map((node) => node.id)), "layout.json");
|
||||||
|
if (errors.length) throw new Error(errors.map((item) => `${item.code}:${item.id}:${item.message}`).join("\n"));
|
||||||
|
return value as LayoutDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadText(filename: string, content: string): void {
|
||||||
|
const blob = new Blob([content], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
export type DeviceScenario = "normal" | "empty" | "retry-success" | "retry-failure";
|
||||||
|
|
||||||
|
export interface DeviceDraft {
|
||||||
|
name: string;
|
||||||
|
type: "gateway" | "sensor";
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: DeviceDraft["type"];
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
location: string;
|
||||||
|
status: "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceMockState {
|
||||||
|
scenario: DeviceScenario;
|
||||||
|
devices: MockDevice[];
|
||||||
|
draft: DeviceDraft;
|
||||||
|
connectionAttempt: number;
|
||||||
|
connectionOutcome: "idle" | "success" | "failure";
|
||||||
|
connectionReason: string | null;
|
||||||
|
saveMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceMockResult {
|
||||||
|
state: DeviceMockState;
|
||||||
|
ok: boolean;
|
||||||
|
errors: string[];
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_DEVICE_DRAFT: DeviceDraft = {
|
||||||
|
name: "",
|
||||||
|
type: "gateway",
|
||||||
|
host: "",
|
||||||
|
port: "1883",
|
||||||
|
location: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEMO_DEVICE: MockDevice = {
|
||||||
|
id: "device.demo-01",
|
||||||
|
name: "Northstar Gateway",
|
||||||
|
type: "gateway",
|
||||||
|
host: "10.24.8.12",
|
||||||
|
port: "1883",
|
||||||
|
location: "研发楼 / 3F",
|
||||||
|
status: "online",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDeviceMock(scenario: DeviceScenario = "normal"): DeviceMockState {
|
||||||
|
return {
|
||||||
|
scenario,
|
||||||
|
devices: scenario === "empty" ? [] : [DEMO_DEVICE],
|
||||||
|
draft: { ...EMPTY_DEVICE_DRAFT },
|
||||||
|
connectionAttempt: 0,
|
||||||
|
connectionOutcome: "idle",
|
||||||
|
connectionReason: null,
|
||||||
|
saveMessage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDeviceDraft(draft: DeviceDraft): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!draft.name.trim()) errors.push("设备名称为必填项");
|
||||||
|
if (!draft.host.trim()) errors.push("主机地址为必填项");
|
||||||
|
if (!draft.port.trim()) errors.push("端口为必填项");
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDraft(state: DeviceMockState, draft: DeviceDraft): DeviceMockState {
|
||||||
|
return { ...state, draft: { ...draft }, saveMessage: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionWillSucceed(scenario: DeviceScenario, attempt: number): boolean {
|
||||||
|
if (scenario === "retry-success") return attempt >= 2;
|
||||||
|
if (scenario === "retry-failure") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic business mock. It never calls a backend and never creates an
|
||||||
|
* entity twice: the stable mock-device id is checked before commit.
|
||||||
|
*/
|
||||||
|
export function reduceDeviceMock(
|
||||||
|
state: DeviceMockState,
|
||||||
|
actionId: string,
|
||||||
|
input: Partial<DeviceDraft> = {},
|
||||||
|
): DeviceMockResult {
|
||||||
|
const nextDraft = { ...state.draft, ...input };
|
||||||
|
if (actionId === "device.create") {
|
||||||
|
const next = { ...state, draft: { ...EMPTY_DEVICE_DRAFT }, saveMessage: null, connectionOutcome: "idle" as const, connectionReason: null };
|
||||||
|
return { state: next, ok: true, errors: [], context: { deviceScenario: state.scenario } };
|
||||||
|
}
|
||||||
|
if (actionId === "device.continue-to-config" || actionId === "device.save-config") {
|
||||||
|
const errors = validateDeviceDraft(nextDraft);
|
||||||
|
if (errors.length) return { state: withDraft(state, nextDraft), ok: false, errors, context: { formValid: false } };
|
||||||
|
const next = { ...withDraft(state, nextDraft), saveMessage: actionId === "device.save-config" ? "配置已保存,正在连接设备" : null };
|
||||||
|
return { state: next, ok: true, errors: [], context: { formValid: true, deviceScenario: state.scenario } };
|
||||||
|
}
|
||||||
|
if (actionId === "device.resolve-connection") {
|
||||||
|
const attempt = state.connectionAttempt + 1;
|
||||||
|
const success = connectionWillSucceed(state.scenario, attempt);
|
||||||
|
const reason = success ? null : "连接超时:设备未在 5 秒内响应";
|
||||||
|
let devices = state.devices;
|
||||||
|
if (success && !devices.some((device) => device.id === "device.mock-new")) {
|
||||||
|
devices = [...devices, {
|
||||||
|
id: "device.mock-new",
|
||||||
|
name: nextDraft.name,
|
||||||
|
type: nextDraft.type,
|
||||||
|
host: nextDraft.host,
|
||||||
|
port: nextDraft.port,
|
||||||
|
location: nextDraft.location,
|
||||||
|
status: "online",
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
const next = { ...withDraft(state, nextDraft), devices, connectionAttempt: attempt, connectionOutcome: success ? "success" as const : "failure" as const, connectionReason: reason };
|
||||||
|
return { state: next, ok: true, errors: [], context: { connectionOutcome: success ? "success" : "failure", connectionReason: reason ?? "", canRetry: !success, deviceScenario: state.scenario } };
|
||||||
|
}
|
||||||
|
if (actionId === "device.retry-connection") {
|
||||||
|
const next = { ...state, connectionOutcome: "idle" as const, connectionReason: null, saveMessage: "正在重试连接" };
|
||||||
|
return { state: next, ok: true, errors: [], context: { canRetry: true, deviceScenario: state.scenario } };
|
||||||
|
}
|
||||||
|
return { state, ok: true, errors: [], context: { deviceScenario: state.scenario } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Registry
|
||||||
|
|
||||||
|
页面、dialog、state 组件和 Action 的共享注册表。validator 与 NavigationRuntime 都从此处解析,
|
||||||
|
新增注册必须同时更新 flow、fixture 和对应测试;组件不得产生第二套导航映射。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import registry from "./registry.json";
|
||||||
|
import type { NodeKind } from "../contracts/flow";
|
||||||
|
|
||||||
|
export interface ComponentRegistration { kinds: NodeKind[]; }
|
||||||
|
export interface ActionRegistration { sourceKinds: NodeKind[]; }
|
||||||
|
const sharedRegistry = registry as unknown as {
|
||||||
|
components: Record<string, ComponentRegistration>;
|
||||||
|
actions: Record<string, ActionRegistration>;
|
||||||
|
};
|
||||||
|
export const componentRegistry: Record<string, ComponentRegistration> = sharedRegistry.components;
|
||||||
|
export const actionRegistry: Record<string, ActionRegistration> = sharedRegistry.actions;
|
||||||
|
|
||||||
|
export function getComponent(id: string): ComponentRegistration | undefined {
|
||||||
|
return componentRegistry[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAction(id: string): ActionRegistration | undefined {
|
||||||
|
return actionRegistry[id];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"components": {
|
||||||
|
"StudioPlaceholder": { "kinds": ["page", "dialog", "state"] },
|
||||||
|
"DeviceList": { "kinds": ["page"] },
|
||||||
|
"DeviceCreate": { "kinds": ["page"] },
|
||||||
|
"DeviceConfig": { "kinds": ["page"] },
|
||||||
|
"DeviceConnecting": { "kinds": ["state"] },
|
||||||
|
"DeviceConnected": { "kinds": ["state"] },
|
||||||
|
"DeviceConnectionFailed": { "kinds": ["state"] },
|
||||||
|
"DeviceEmpty": { "kinds": ["state"] },
|
||||||
|
"DeviceDetail": { "kinds": ["page"] }
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"action.studio.noop": { "sourceKinds": ["page", "dialog", "state"] },
|
||||||
|
"action.studio.open-help": { "sourceKinds": ["page"] },
|
||||||
|
"action.studio.close-help": { "sourceKinds": ["dialog"] },
|
||||||
|
"action.studio.show-empty": { "sourceKinds": ["page"] },
|
||||||
|
"action.studio.back": { "sourceKinds": ["state", "dialog"] },
|
||||||
|
"device.open-management": { "sourceKinds": ["page"] },
|
||||||
|
"device.create": { "sourceKinds": ["page", "state"] },
|
||||||
|
"device.show-empty": { "sourceKinds": ["page"] },
|
||||||
|
"device.open-detail": { "sourceKinds": ["page", "state"] },
|
||||||
|
"device.back-to-list": { "sourceKinds": ["page", "state"] },
|
||||||
|
"device.continue-to-config": { "sourceKinds": ["page"] },
|
||||||
|
"device.back-to-create": { "sourceKinds": ["page"] },
|
||||||
|
"device.save-config": { "sourceKinds": ["page"] },
|
||||||
|
"device.resolve-connection": { "sourceKinds": ["state"] },
|
||||||
|
"device.retry-connection": { "sourceKinds": ["state"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Review export
|
||||||
|
|
||||||
|
`addReviewComment` 在写入前校验 node/edge/page 稳定 ID;`exportReview` 再次校验所有目标,
|
||||||
|
所以悬空批注会显式失败而不是静默丢弃。Studio 的 Review 面板支持状态更新、批注绑定和
|
||||||
|
`review.json` 下载。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FlowDocument } from "../contracts/flow";
|
||||||
|
import type { ReviewComment, ReviewDocument, ReviewTarget, ReviewTargetKind } from "../contracts/review";
|
||||||
|
|
||||||
|
export interface ReviewExportError { code: "DANGLING_TARGET" | "INVALID_TARGET"; message: string; id: string; }
|
||||||
|
|
||||||
|
function targetExists(flow: FlowDocument, target: ReviewTarget): boolean {
|
||||||
|
if (target.kind === "edge") return flow.edges.some((edge) => edge.id === target.id);
|
||||||
|
const node = flow.nodes.find((candidate) => candidate.id === target.id);
|
||||||
|
return Boolean(node && (target.kind !== "page" || node.kind === "page"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertReviewTarget(flow: FlowDocument, target: ReviewTarget): void {
|
||||||
|
if (!["node", "edge", "page"].includes(target.kind) || !target.id) throw new Error(`INVALID_TARGET:${target.id || "missing"}:review target kind/id is invalid`);
|
||||||
|
if (!targetExists(flow, target)) throw new Error(`DANGLING_TARGET:${target.id}:review target does not exist in flow`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addReviewComment(review: ReviewDocument, flow: FlowDocument, comment: ReviewComment): ReviewDocument {
|
||||||
|
assertReviewTarget(flow, comment.target);
|
||||||
|
if (review.comments.some((existing) => existing.id === comment.id)) throw new Error(`DUPLICATE_ID:${comment.id}:review comment already exists`);
|
||||||
|
return { ...review, comments: [...review.comments, comment] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateReviewStatus(review: ReviewDocument, status: ReviewDocument["status"]): ReviewDocument {
|
||||||
|
return { ...review, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportReview(review: ReviewDocument, flow: FlowDocument): string {
|
||||||
|
review.comments.forEach((comment) => assertReviewTarget(flow, comment.target));
|
||||||
|
return `${JSON.stringify(review, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewTargetLabel(target: ReviewTarget): string {
|
||||||
|
return `${target.kind}:${target.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ReviewTargetKind };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Navigation runtime
|
||||||
|
|
||||||
|
`NavigationRuntime` 的 `dispatch(currentNodeId, actionId, context)` 只从传入的 `FlowDocument`
|
||||||
|
和共享 Action registry 查找 transition。它依次检查来源节点、Action/source kind、悬空目标和
|
||||||
|
结构化条件;任何失败都返回包含 `code`、`message`、来源/Action/边 ID 的错误,并停留在当前
|
||||||
|
节点。runtime 不拼 URL、不连接后端、不使用 localStorage,也不执行 `eval`/`Function`。
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Condition, FlowDocument, FlowEdge, FlowNode, NodeKind, Primitive } from "../contracts/flow";
|
||||||
|
import { actionRegistry, getAction } from "../registry";
|
||||||
|
import { evaluateCondition, validateCondition } from "../schema/validator";
|
||||||
|
|
||||||
|
export type RuntimeErrorCode =
|
||||||
|
| "UNKNOWN_SOURCE"
|
||||||
|
| "UNKNOWN_ACTION"
|
||||||
|
| "SOURCE_MISMATCH"
|
||||||
|
| "NO_TRANSITION"
|
||||||
|
| "CONDITION_NOT_MET"
|
||||||
|
| "INVALID_CONDITION"
|
||||||
|
| "DANGLING_TARGET";
|
||||||
|
|
||||||
|
export interface RuntimeError {
|
||||||
|
code: RuntimeErrorCode;
|
||||||
|
message: string;
|
||||||
|
currentNodeId: string;
|
||||||
|
actionId: string;
|
||||||
|
edgeId?: string;
|
||||||
|
targetNodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchSuccess {
|
||||||
|
ok: true;
|
||||||
|
nodeId: string;
|
||||||
|
edgeId: string;
|
||||||
|
actionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchFailure {
|
||||||
|
ok: false;
|
||||||
|
error: RuntimeError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DispatchResult = DispatchSuccess | DispatchFailure;
|
||||||
|
export type RuntimeContext = Record<string, unknown>;
|
||||||
|
|
||||||
|
const failure = (
|
||||||
|
code: RuntimeErrorCode,
|
||||||
|
message: string,
|
||||||
|
currentNodeId: string,
|
||||||
|
actionId: string,
|
||||||
|
edge?: FlowEdge,
|
||||||
|
): DispatchFailure => ({
|
||||||
|
ok: false,
|
||||||
|
error: { code, message, currentNodeId, actionId, ...(edge ? { edgeId: edge.id, targetNodeId: edge.target } : {}) },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve navigation exclusively from the supplied flow document and shared action registry.
|
||||||
|
* This is intentionally a pure runtime: no URL, network, or random IDs.
|
||||||
|
*/
|
||||||
|
export class NavigationRuntime {
|
||||||
|
constructor(private readonly flow: FlowDocument) {}
|
||||||
|
|
||||||
|
getNode(nodeId: string): FlowNode | undefined {
|
||||||
|
return this.flow.nodes.find((node) => node.id === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoing(nodeId: string): FlowEdge[] {
|
||||||
|
return this.flow.edges.filter((edge) => edge.source === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(currentNodeId: string, actionId: string, context: RuntimeContext = {}): DispatchResult {
|
||||||
|
const current = this.getNode(currentNodeId);
|
||||||
|
if (!current) return failure("UNKNOWN_SOURCE", `Current node ${currentNodeId} does not exist in flow`, currentNodeId, actionId);
|
||||||
|
|
||||||
|
const action = getAction(actionId);
|
||||||
|
if (!action) return failure("UNKNOWN_ACTION", `Action ${actionId} is not registered`, currentNodeId, actionId);
|
||||||
|
if (!action.sourceKinds.includes(current.kind)) {
|
||||||
|
return failure("SOURCE_MISMATCH", `Action ${actionId} is not allowed from ${current.kind} node ${currentNodeId}`, currentNodeId, actionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = this.outgoing(currentNodeId).filter((edge) => edge.actionId === actionId);
|
||||||
|
if (!candidates.length) return failure("NO_TRANSITION", `No transition for ${actionId} from ${currentNodeId}`, currentNodeId, actionId);
|
||||||
|
|
||||||
|
// A dangling candidate is an authoring error, never silently skipped by runtime.
|
||||||
|
const nodeIds = new Set(this.flow.nodes.map((node) => node.id));
|
||||||
|
const dangling = candidates.find((edge) => !nodeIds.has(edge.target));
|
||||||
|
if (dangling) return failure("DANGLING_TARGET", `Transition ${dangling.id} targets missing node ${dangling.target}`, currentNodeId, actionId, dangling);
|
||||||
|
|
||||||
|
let conditionRejected = false;
|
||||||
|
for (const edge of candidates) {
|
||||||
|
if (edge.when) {
|
||||||
|
const conditionErrors = validateCondition(edge.when, "flow.json", edge.id);
|
||||||
|
if (conditionErrors.length) return failure("INVALID_CONDITION", conditionErrors[0].message, currentNodeId, actionId, edge);
|
||||||
|
if (!evaluateCondition(edge.when as Condition, context)) {
|
||||||
|
conditionRejected = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, nodeId: edge.target, edgeId: edge.id, actionId };
|
||||||
|
}
|
||||||
|
return conditionRejected
|
||||||
|
? failure("CONDITION_NOT_MET", `No condition for ${actionId} from ${currentNodeId} matched the supplied context`, currentNodeId, actionId)
|
||||||
|
: failure("NO_TRANSITION", `No usable transition for ${actionId} from ${currentNodeId}`, currentNodeId, actionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNavigationRuntime(flow: FlowDocument): NavigationRuntime {
|
||||||
|
return new NavigationRuntime(flow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatch(
|
||||||
|
flow: FlowDocument,
|
||||||
|
currentNodeId: string,
|
||||||
|
actionId: string,
|
||||||
|
context: RuntimeContext = {},
|
||||||
|
): DispatchResult {
|
||||||
|
return new NavigationRuntime(flow).dispatch(currentNodeId, actionId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { actionRegistry };
|
||||||
|
export type { Condition, NodeKind, Primitive };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export type ValidationCode =
|
||||||
|
| "FILE_READ"
|
||||||
|
| "JSON_PARSE"
|
||||||
|
| "SCHEMA_INVALID"
|
||||||
|
| "DUPLICATE_ID"
|
||||||
|
| "MISSING_START"
|
||||||
|
| "INVALID_START"
|
||||||
|
| "DANGLING_REFERENCE"
|
||||||
|
| "UNKNOWN_COMPONENT"
|
||||||
|
| "UNKNOWN_ACTION"
|
||||||
|
| "INVALID_CONDITION"
|
||||||
|
| "UNREACHABLE_NODE"
|
||||||
|
| "LAYOUT_CONFLICT";
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
file: string;
|
||||||
|
id: string;
|
||||||
|
code: ValidationCode;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./errors";
|
||||||
|
export * from "./validator";
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type { Condition, FlowDocument, FlowEdge, FlowNode, Primitive } from "../contracts/flow";
|
||||||
|
import type { LayoutDocument, LayoutNode, LayoutPatch, Position } from "../contracts/layout";
|
||||||
|
import type { ReviewDocument } from "../contracts/review";
|
||||||
|
import { actionRegistry, componentRegistry } from "../registry";
|
||||||
|
import type { ValidationError } from "./errors";
|
||||||
|
|
||||||
|
const NODE_KINDS = new Set(["page", "dialog", "state"]);
|
||||||
|
const REVIEW_STATUSES = new Set(["draft", "in-review", "approved", "changes-requested"]);
|
||||||
|
const TARGET_KINDS = new Set(["node", "edge", "page"]);
|
||||||
|
const CONDITION_OPS = new Set(["eq", "neq", "in", "notIn", "exists", "truthy", "falsy", "and", "or", "not"]);
|
||||||
|
const SAFE_ID = /^[A-Za-z][A-Za-z0-9._:-]*$/;
|
||||||
|
const SAFE_FIELD = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
||||||
|
const RESERVED_FIELDS = new Set(["__proto__", "prototype", "constructor"]);
|
||||||
|
|
||||||
|
const error = (file: string, id: string, code: ValidationError["code"], message: string): ValidationError => ({ file, id, code, message });
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
const isPrimitive = (value: unknown): value is Primitive => value === null || ["string", "number", "boolean"].includes(typeof value);
|
||||||
|
const isId = (value: unknown): value is string => typeof value === "string" && SAFE_ID.test(value);
|
||||||
|
const idList = (values: unknown, file: string, prefix: string, errors: ValidationError[]): string[] => {
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
errors.push(error(file, prefix, "SCHEMA_INVALID", `${prefix} must be an array`));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
const id = typeof value === "string" ? value : `${prefix}[${index}]`;
|
||||||
|
if (!isId(value)) errors.push(error(file, id, "SCHEMA_INVALID", `${prefix} contains an invalid stable ID`));
|
||||||
|
else if (seen.has(value)) errors.push(error(file, value, "DUPLICATE_ID", `${prefix} contains duplicate stable ID ${value}`));
|
||||||
|
else seen.add(value);
|
||||||
|
});
|
||||||
|
return values.filter((value): value is string => typeof value === "string");
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateRequirements(markdown: string, file = "requirements.md"): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
if (typeof markdown !== "string" || markdown.trim() === "") {
|
||||||
|
return [error(file, "requirements", "SCHEMA_INVALID", "requirements.md must be non-empty markdown")];
|
||||||
|
}
|
||||||
|
for (const section of ["Goal", "Scenario", "Acceptance context"] as const) {
|
||||||
|
const match = markdown.match(new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "mi"));
|
||||||
|
if (!match) errors.push(error(file, `requirements.${section.toLowerCase().split(" ").join("-")}`, "SCHEMA_INVALID", `missing required ## ${section} section`));
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCondition(condition: unknown, file: string, id = "condition"): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
const visit = (value: unknown, currentId: string): void => {
|
||||||
|
if (!isRecord(value) || typeof value.op !== "string" || !CONDITION_OPS.has(value.op)) {
|
||||||
|
errors.push(error(file, currentId, "INVALID_CONDITION", "condition must use a whitelisted structured operator"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const op = value.op;
|
||||||
|
if (["eq", "neq", "in", "notIn", "exists", "truthy", "falsy"].includes(op)) {
|
||||||
|
if (typeof value.field !== "string" || !SAFE_FIELD.test(value.field) || RESERVED_FIELDS.has(value.field)) {
|
||||||
|
errors.push(error(file, currentId, "INVALID_CONDITION", "condition field must be a safe dotted path"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (["eq", "neq"].includes(op) && !isPrimitive(value.value)) errors.push(error(file, currentId, "INVALID_CONDITION", `${op} value must be a primitive`));
|
||||||
|
if (["in", "notIn"].includes(op) && (!Array.isArray(value.value) || !value.value.every(isPrimitive))) errors.push(error(file, currentId, "INVALID_CONDITION", `${op} value must be an array of primitives`));
|
||||||
|
if (["and", "or"].includes(op)) {
|
||||||
|
if (!Array.isArray(value.conditions) || value.conditions.length === 0) errors.push(error(file, currentId, "INVALID_CONDITION", `${op} requires a non-empty conditions array`));
|
||||||
|
else value.conditions.forEach((child, index) => visit(child, `${currentId}.conditions[${index}]`));
|
||||||
|
}
|
||||||
|
if (op === "not") {
|
||||||
|
if (!("condition" in value)) errors.push(error(file, currentId, "INVALID_CONDITION", "not requires a nested condition"));
|
||||||
|
else visit(value.condition, `${currentId}.condition`);
|
||||||
|
}
|
||||||
|
const allowed = op === "not" ? new Set(["op", "condition"]) : ["and", "or"].includes(op) ? new Set(["op", "conditions"]) : ["exists", "truthy", "falsy"].includes(op) ? new Set(["op", "field"]) : new Set(["op", "field", "value"]);
|
||||||
|
Object.keys(value).filter((key) => !allowed.has(key)).forEach((key) => errors.push(error(file, currentId, "INVALID_CONDITION", `unknown condition property ${key}`)));
|
||||||
|
};
|
||||||
|
visit(condition, id);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateCondition(condition: Condition, context: Record<string, unknown>): boolean {
|
||||||
|
const read = (field: string): unknown => field.split(".").reduce<unknown>((current, key) => isRecord(current) ? current[key] : undefined, context);
|
||||||
|
switch (condition.op) {
|
||||||
|
case "eq": return read(condition.field) === condition.value;
|
||||||
|
case "neq": return read(condition.field) !== condition.value;
|
||||||
|
case "in": return condition.value.some((candidate) => candidate === read(condition.field));
|
||||||
|
case "notIn": return !condition.value.some((candidate) => candidate === read(condition.field));
|
||||||
|
case "exists": return read(condition.field) !== undefined && read(condition.field) !== null;
|
||||||
|
case "truthy": return Boolean(read(condition.field));
|
||||||
|
case "falsy": return !read(condition.field);
|
||||||
|
case "and": return condition.conditions.every((child) => evaluateCondition(child, context));
|
||||||
|
case "or": return condition.conditions.some((child) => evaluateCondition(child, context));
|
||||||
|
case "not": return !evaluateCondition(condition.condition, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNode(value: unknown, file: string, id: string, errors: ValidationError[]): value is FlowNode {
|
||||||
|
if (!isRecord(value) || !isId(value.id) || typeof value.kind !== "string" || !NODE_KINDS.has(value.kind) || typeof value.component !== "string" || typeof value.title !== "string") {
|
||||||
|
errors.push(error(file, id, "SCHEMA_INVALID", "node requires id, kind, component and title"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!componentRegistry[value.component]) errors.push(error(file, value.id, "UNKNOWN_COMPONENT", `component ${value.component} is not registered`));
|
||||||
|
else if (!componentRegistry[value.component].kinds.includes(value.kind as "page" | "dialog" | "state")) errors.push(error(file, value.id, "UNKNOWN_COMPONENT", `component ${value.component} cannot render node kind ${value.kind}`));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFlow(value: unknown, file = "flow.json"): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
if (!isRecord(value) || value.flowVersion !== 1 || !isId(value.startNodeId) || !Array.isArray(value.nodes) || !Array.isArray(value.edges)) {
|
||||||
|
return [error(file, "flow", "SCHEMA_INVALID", "flow requires flowVersion 1, startNodeId, nodes and edges")];
|
||||||
|
}
|
||||||
|
const nodes = value.nodes as unknown[];
|
||||||
|
const edges = value.edges as unknown[];
|
||||||
|
const nodeMap = new Map<string, FlowNode>();
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const id = isRecord(node) && typeof node.id === "string" ? node.id : `nodes[${index}]`;
|
||||||
|
if (!validateNode(node, file, id, errors)) return;
|
||||||
|
if (nodeMap.has(node.id)) errors.push(error(file, node.id, "DUPLICATE_ID", `duplicate node ID ${node.id}`));
|
||||||
|
else nodeMap.set(node.id, node as FlowNode);
|
||||||
|
});
|
||||||
|
if (!nodeMap.size) errors.push(error(file, "flow.startNodeId", "MISSING_START", "flow must declare exactly one start node"));
|
||||||
|
else if (!nodeMap.has(value.startNodeId)) errors.push(error(file, value.startNodeId, "INVALID_START", "startNodeId does not reference a node"));
|
||||||
|
const edgeMap = new Map<string, FlowEdge>();
|
||||||
|
edges.forEach((edge, index) => {
|
||||||
|
const id = isRecord(edge) && typeof edge.id === "string" ? edge.id : `edges[${index}]`;
|
||||||
|
if (!isRecord(edge) || !isId(edge.id) || typeof edge.source !== "string" || typeof edge.target !== "string" || typeof edge.actionId !== "string") {
|
||||||
|
errors.push(error(file, id, "SCHEMA_INVALID", "edge requires id, source, target and actionId"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (edgeMap.has(edge.id)) errors.push(error(file, edge.id, "DUPLICATE_ID", `duplicate edge ID ${edge.id}`));
|
||||||
|
else edgeMap.set(edge.id, edge as unknown as FlowEdge);
|
||||||
|
if (!nodeMap.has(edge.source)) errors.push(error(file, edge.id, "DANGLING_REFERENCE", `edge source ${edge.source} does not exist`));
|
||||||
|
if (!nodeMap.has(edge.target)) errors.push(error(file, edge.id, "DANGLING_REFERENCE", `edge target ${edge.target} does not exist`));
|
||||||
|
const action = actionRegistry[edge.actionId];
|
||||||
|
if (!action) errors.push(error(file, edge.actionId, "UNKNOWN_ACTION", `action ${edge.actionId} is not registered`));
|
||||||
|
else if (nodeMap.has(edge.source) && !action.sourceKinds.includes(nodeMap.get(edge.source)!.kind)) errors.push(error(file, edge.actionId, "UNKNOWN_ACTION", `action ${edge.actionId} is not allowed from ${nodeMap.get(edge.source)!.kind}`));
|
||||||
|
if ("when" in edge) errors.push(...validateCondition(edge.when, file, edge.id));
|
||||||
|
});
|
||||||
|
if (nodeMap.has(value.startNodeId)) {
|
||||||
|
const reachable = new Set<string>([value.startNodeId]);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const edge of edgeMap.values()) if (reachable.has(edge.source) && nodeMap.has(edge.target) && !reachable.has(edge.target)) { reachable.add(edge.target); changed = true; }
|
||||||
|
}
|
||||||
|
nodeMap.forEach((_node, id) => { if (!reachable.has(id)) errors.push(error(file, id, "UNREACHABLE_NODE", `node ${id} is not reachable from ${value.startNodeId}`)); });
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPosition = (value: unknown): value is LayoutNode => isRecord(value) && typeof value.x === "number" && Number.isFinite(value.x) && typeof value.y === "number" && Number.isFinite(value.y) && (value.width === undefined || (typeof value.width === "number" && Number.isFinite(value.width))) && (value.height === undefined || (typeof value.height === "number" && Number.isFinite(value.height)));
|
||||||
|
|
||||||
|
export function validateLayout(value: unknown, nodeIds: Set<string>, file = "layout.json"): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
if (!isRecord(value) || value.layoutVersion !== 1 || !isRecord(value.viewport) || !isRecord(value.nodes) || typeof value.viewport.x !== "number" || !Number.isFinite(value.viewport.x) || typeof value.viewport.y !== "number" || !Number.isFinite(value.viewport.y) || typeof value.viewport.zoom !== "number" || !Number.isFinite(value.viewport.zoom) || value.viewport.zoom <= 0) return [error(file, "layout", "SCHEMA_INVALID", "layout requires version 1, viewport and nodes")];
|
||||||
|
Object.entries(value.nodes).forEach(([id, node]) => {
|
||||||
|
if (!nodeIds.has(id)) errors.push(error(file, id, "DANGLING_REFERENCE", `layout node ${id} does not exist in flow`));
|
||||||
|
if (!validPosition(node)) errors.push(error(file, id, "SCHEMA_INVALID", `layout node ${id} requires finite x/y coordinates`));
|
||||||
|
});
|
||||||
|
if (value.groups !== undefined && isRecord(value.groups)) Object.entries(value.groups).forEach(([groupId, group]) => {
|
||||||
|
if (!isRecord(group) || !Array.isArray(group.nodeIds)) errors.push(error(file, groupId, "SCHEMA_INVALID", "layout group requires nodeIds"));
|
||||||
|
else idList(group.nodeIds, file, `groups.${groupId}.nodeIds`, errors).forEach((id) => { if (!nodeIds.has(id)) errors.push(error(file, id, "DANGLING_REFERENCE", `layout group references unknown node ${id}`)); });
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateReview(value: unknown, nodeIds: Set<string>, edgeIds: Set<string>, file = "review.json", nodeKinds?: Map<string, string>): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
if (!isRecord(value) || value.reviewVersion !== 1 || typeof value.status !== "string" || !REVIEW_STATUSES.has(value.status) || !Array.isArray(value.comments)) return [error(file, "review", "SCHEMA_INVALID", "review requires version 1, status and comments")];
|
||||||
|
const commentIds = new Set<string>();
|
||||||
|
value.comments.forEach((comment, index) => {
|
||||||
|
const id = isRecord(comment) && typeof comment.id === "string" ? comment.id : `comments[${index}]`;
|
||||||
|
if (!isRecord(comment) || !isId(comment.id) || typeof comment.body !== "string" || !isRecord(comment.target) || typeof comment.target.kind !== "string" || typeof comment.target.id !== "string" || !TARGET_KINDS.has(comment.target.kind)) { errors.push(error(file, id, "SCHEMA_INVALID", "comment requires id, body and a node/edge/page target")); return; }
|
||||||
|
if (commentIds.has(comment.id)) errors.push(error(file, comment.id, "DUPLICATE_ID", `duplicate comment ID ${comment.id}`)); else commentIds.add(comment.id);
|
||||||
|
const exists = comment.target.kind === "edge" ? edgeIds.has(comment.target.id) : nodeIds.has(comment.target.id);
|
||||||
|
if (!exists) errors.push(error(file, comment.target.id, "DANGLING_REFERENCE", `review target ${comment.target.id} does not exist in flow`));
|
||||||
|
if (comment.target.kind === "page" && nodeKinds?.get(comment.target.id) !== undefined && nodeKinds.get(comment.target.id) !== "page") errors.push(error(file, comment.target.id, "DANGLING_REFERENCE", `review page target ${comment.target.id} is not a page node`));
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeLayouts(base: LayoutDocument, patch: LayoutPatch, activeNodeIds?: Set<string>): LayoutDocument {
|
||||||
|
const nodes: Record<string, LayoutNode> = { ...base.nodes };
|
||||||
|
for (const id of patch.removeNodeIds ?? []) delete nodes[id];
|
||||||
|
for (const [id, incoming] of Object.entries(patch.nodes ?? {})) {
|
||||||
|
const { expected, ...next } = incoming;
|
||||||
|
if (expected && nodes[id] && (nodes[id].x !== expected.x || nodes[id].y !== expected.y)) throw new Error(`LAYOUT_CONFLICT:${id}:base coordinates changed since patch was created`);
|
||||||
|
nodes[id] = next;
|
||||||
|
}
|
||||||
|
if (activeNodeIds) Object.keys(nodes).forEach((id) => { if (!activeNodeIds.has(id)) delete nodes[id]; });
|
||||||
|
return { ...base, viewport: patch.viewport ?? base.viewport, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDocuments(input: { requirements: string; flow: unknown; layout: unknown; review: unknown; files?: { requirements?: string; flow?: string; layout?: string; review?: string } }): ValidationError[] {
|
||||||
|
const flowFile = input.files?.flow ?? "flow.json";
|
||||||
|
const flowErrors = validateFlow(input.flow, flowFile);
|
||||||
|
const flow = input.flow as Record<string, unknown>;
|
||||||
|
const nodes = Array.isArray(flow?.nodes) ? flow.nodes.filter(isRecord) : [];
|
||||||
|
const edges = Array.isArray(flow?.edges) ? flow.edges.filter(isRecord) : [];
|
||||||
|
const nodeIds = new Set(nodes.map((node) => typeof node.id === "string" ? node.id : "").filter(Boolean));
|
||||||
|
const nodeKinds = new Map(nodes.filter((node) => typeof node.id === "string" && typeof node.kind === "string").map((node) => [node.id as string, node.kind as string]));
|
||||||
|
const edgeIds = new Set(edges.map((edge) => typeof edge.id === "string" ? edge.id : "").filter(Boolean));
|
||||||
|
return [...validateRequirements(input.requirements, input.files?.requirements ?? "requirements.md"), ...flowErrors, ...validateLayout(input.layout, nodeIds, input.files?.layout ?? "layout.json"), ...validateReview(input.review, nodeIds, edgeIds, input.files?.review ?? "review.json", nodeKinds)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Prototype Studio
|
||||||
|
|
||||||
|
`App.tsx` 是离线 Studio 入口:从 `prototype/flow.json`、`layout.json` 和 `review.json`
|
||||||
|
加载数据,使用 React Flow 渲染 page/dialog/state 节点、箭头边、Action/条件标签、缩略图和
|
||||||
|
占位提示。右侧 inspector/preview 只显示稳定 ID 与注册组件;`Controls` 提供 Fit View、缩放
|
||||||
|
和平移,`MiniMap` 提供全局定位;选中节点/边可查看稳定 ID、Action 和条件,右侧 preview 通过
|
||||||
|
统一 NavigationRuntime 驱动注册组件。
|
||||||
|
|
||||||
|
布局不会写 localStorage。作者必须点击 `Save layout.json` 导出当前坐标和 viewport,使用
|
||||||
|
`Restore layout.json` 选择经过校验的文件恢复;含悬空节点 ID 的文件会显示错误且不被应用。
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
:root {
|
||||||
|
color: #e6edf7;
|
||||||
|
background: #0e1726;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
button { border: 1px solid #3d5e82; border-radius: 6px; padding: 7px 10px; color: #d7e9fb; background: #1a304b; cursor: pointer; font-size: .74rem; }
|
||||||
|
button:hover { border-color: #74c0fc; background: #234463; }
|
||||||
|
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
|
||||||
|
.studio-shell { min-height: 100vh; background: #0e1726; }
|
||||||
|
.studio-header { display: flex; align-items: center; justify-content: space-between; padding: 24px 30px; border-bottom: 1px solid #24344c; }
|
||||||
|
.studio-header h1 { margin: 3px 0 0; font-size: 1.35rem; letter-spacing: -.02em; }
|
||||||
|
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.eyebrow { margin: 0; color: #75b7ff; font-size: .7rem; font-weight: 700; letter-spacing: .16em; }
|
||||||
|
.status-pill { padding: 5px 10px; border: 1px solid #3a5274; border-radius: 999px; color: #a9c9eb; font-size: .78rem; }
|
||||||
|
.studio-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr) 240px; height: calc(100vh - 86px); }
|
||||||
|
.source-panel, .preview-panel { padding: 22px; background: #142238; }
|
||||||
|
.source-panel { border-right: 1px solid #24344c; }
|
||||||
|
.preview-panel { border-left: 1px solid #24344c; }
|
||||||
|
.source-panel h2, .preview-panel h2 { margin: 0 0 10px; font-size: .95rem; }
|
||||||
|
.source-panel p, .preview-panel p { color: #9bb0c9; font-size: .82rem; line-height: 1.55; }
|
||||||
|
.source-panel ul { padding-left: 18px; color: #d8e4f2; font-size: .82rem; line-height: 2; }
|
||||||
|
.source-summary { display: grid; gap: 5px; margin: 16px 0 20px; color: #7d9dbd; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.requirements-preview { padding: 9px; border-left: 2px solid #477298; background: #102035; font-size: .72rem !important; }
|
||||||
|
.source-panel label, .preview-panel label { display: grid; gap: 5px; margin: 12px 0; color: #9bb0c9; font-size: .72rem; }
|
||||||
|
.source-panel select, .source-panel textarea { width: 100%; border: 1px solid #324c6b; border-radius: 5px; padding: 7px; color: #d8e4f2; background: #0e1a2c; }
|
||||||
|
.source-panel textarea { resize: vertical; }
|
||||||
|
.comment-label span { color: #c5ddf7; font-family: ui-monospace, monospace; font-size: .68rem; overflow-wrap: anywhere; }
|
||||||
|
.button-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
code { color: #b8dbff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
|
||||||
|
.canvas-panel { position: relative; min-width: 0; background: #101b2d; }
|
||||||
|
.canvas-notice { position: absolute; z-index: 2; top: 22px; left: 24px; display: grid; gap: 4px; padding: 12px 14px; border: 1px solid #324965; border-radius: 8px; background: #142238e8; }
|
||||||
|
.canvas-notice strong { font-size: .85rem; }
|
||||||
|
.canvas-notice span { color: #9bb0c9; font-size: .76rem; }
|
||||||
|
.react-flow { background: #101b2d; }
|
||||||
|
.prototype-node { width: 220px; min-height: 148px; padding: 12px; border: 1px solid #3a5879; border-radius: 8px; background: #172940; box-shadow: 0 8px 24px #08101d66; }
|
||||||
|
.prototype-node.is-selected { border-color: #b8dbff; box-shadow: 0 0 0 2px #74c0fc55, 0 8px 24px #08101d66; }
|
||||||
|
.node-kind { font-size: .62rem; font-weight: 800; letter-spacing: .13em; }
|
||||||
|
.node-title { margin-top: 4px; color: #edf6ff; font-weight: 700; }
|
||||||
|
.node-id { display: block; margin-top: 5px; color: #9fc7ea; font-size: .66rem; overflow-wrap: anywhere; }
|
||||||
|
.node-thumbnail { display: block; width: 100%; height: 50px; margin-top: 9px; border-radius: 4px; object-fit: cover; background: #0d192a; }
|
||||||
|
.placeholder-thumbnail { display: grid; place-items: center; color: #6f89a5; border: 1px dashed #3b5877; font-size: .65rem; }
|
||||||
|
.node-component { margin-top: 7px; color: #7f9ab5; font-size: .67rem; }
|
||||||
|
.preview-panel { overflow: auto; }
|
||||||
|
.runtime-status { margin: 0 0 13px; padding: 9px; border: 1px solid #345675; border-radius: 6px; color: #b8dbff; background: #10253b; font-size: .72rem; line-height: 1.4; }
|
||||||
|
.runtime-status[data-error="true"] { border-color: #b85c67; color: #ffb8c1; background: #351d2a; }
|
||||||
|
.preview-card { padding: 12px; border: 1px solid #324c6b; border-radius: 8px; background: #102036; }
|
||||||
|
.preview-card h3 { margin: 5px 0; color: #edf6ff; font-size: 1rem; }
|
||||||
|
.preview-card code { display: block; overflow-wrap: anywhere; }
|
||||||
|
.preview-placeholder { display: grid; min-height: 90px; place-items: center; margin-top: 10px; border: 1px dashed #3b5877; border-radius: 5px; color: #7894b2; font-size: .7rem; text-align: center; }
|
||||||
|
.preview-placeholder img { width: 100%; height: 90px; object-fit: cover; border-radius: 4px; }
|
||||||
|
.runtime-actions { margin-top: 15px; }
|
||||||
|
.runtime-actions h3 { margin: 0 0 7px; color: #d6e8fa; font-size: .78rem; }
|
||||||
|
.runtime-actions > button { display: grid; width: 100%; margin: 6px 0; text-align: left; }
|
||||||
|
.runtime-actions button small { margin-top: 3px; color: #89a8c8; font-size: .62rem; overflow-wrap: anywhere; }
|
||||||
|
.checkbox-label { display: flex !important; grid-template-columns: auto 1fr !important; align-items: center; }
|
||||||
|
.checkbox-label input { width: auto; }
|
||||||
|
.inspector { margin-top: 17px; padding-top: 13px; border-top: 1px solid #2b4562; }
|
||||||
|
.inspector h2 { margin-bottom: 5px; }
|
||||||
|
.inspector p { overflow-wrap: anywhere; }
|
||||||
|
.muted { color: #7186a0 !important; }
|
||||||
|
.mock-selector { margin-top: 16px !important; padding-top: 13px; border-top: 1px solid #263d59; }
|
||||||
|
.device-page { min-width: 0; padding: 22px 18px 26px; color: #e9f3ff; background: #f7faff; border: 1px solid #d8e4f0; border-radius: 12px; box-shadow: 0 12px 30px #07152522; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
|
||||||
|
.device-page h3 { margin: 4px 0 6px; color: #12233a; font-size: 1.42rem; letter-spacing: -.03em; }
|
||||||
|
.device-page h4 { margin: 0 0 5px; color: #1c3550; font-size: .86rem; }
|
||||||
|
.device-page p { color: #61768c; font-size: .73rem; line-height: 1.55; }
|
||||||
|
.device-breadcrumb { margin-bottom: 18px; color: #7890a6; font-size: .64rem; }
|
||||||
|
.device-breadcrumb span { padding: 0 6px; color: #b5c5d3; }
|
||||||
|
.device-page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
|
.device-page-heading.compact { margin-bottom: 17px; }
|
||||||
|
.device-overline { margin: 0 !important; color: #3975b4 !important; font-size: .56rem !important; font-weight: 800; letter-spacing: .16em; }
|
||||||
|
.device-subtitle { margin: 0 0 15px !important; }
|
||||||
|
.step-indicator { padding-top: 5px; color: #8ea7bf; font-size: .65rem; font-weight: 700; }
|
||||||
|
.step-indicator i { display: inline-block; width: 22px; height: 1px; margin: 0 6px 3px; background: #9db4ca; }
|
||||||
|
.device-action { border: 0; border-radius: 7px; padding: 9px 12px; color: white; background: #1b6eb5; font-size: .7rem; font-weight: 700; white-space: nowrap; box-shadow: 0 4px 9px #1b6eb533; }
|
||||||
|
.device-action:hover { border: 0; background: #15598f; }
|
||||||
|
.device-action.secondary { color: #315474; border: 1px solid #b9ccdd; background: transparent; box-shadow: none; }
|
||||||
|
.device-action.secondary:hover { border-color: #6d9ac0; background: #edf5fc; }
|
||||||
|
.device-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 8px 0 10px; }
|
||||||
|
.device-search { flex: 1; padding: 8px 10px; border: 1px solid #d9e4ed; border-radius: 6px; color: #8ca1b2; background: white; font-size: .67rem; }
|
||||||
|
.device-search:first-letter { color: #4678a7; }
|
||||||
|
.device-count { color: #7890a6; font-size: .64rem; white-space: nowrap; }
|
||||||
|
.device-table { overflow: hidden; border: 1px solid #d9e4ed; border-radius: 8px; background: white; }
|
||||||
|
.device-table-row { display: grid; grid-template-columns: 1.45fr .75fr 1.25fr 1fr .62fr .62fr; align-items: center; min-height: 50px; padding: 7px 9px; border-top: 1px solid #edf2f6; color: #4e657c; font-size: .63rem; gap: 8px; }
|
||||||
|
.device-table-row:first-child { border-top: 0; }
|
||||||
|
.device-table-head { min-height: 32px; color: #8aa0b4; background: #f5f8fb; font-size: .57rem; font-weight: 800; }
|
||||||
|
.device-name { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||||
|
.device-name strong { display: block; overflow: hidden; color: #1d3b59; font-size: .66rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.device-name small { display: block; margin-top: 3px; color: #95a8b9; font: .56rem ui-monospace, monospace; }
|
||||||
|
.device-icon { display: grid; flex: 0 0 auto; width: 25px; height: 25px; place-items: center; border-radius: 7px; color: #2573b3; background: #e6f2fc; font-size: .82rem; }
|
||||||
|
.device-icon.large { width: 42px; height: 42px; font-size: 1.3rem; }
|
||||||
|
.mono { font-family: ui-monospace, monospace; font-size: .6rem !important; }
|
||||||
|
.status-dot { display: inline-block; width: 6px; height: 6px; margin-right: 4px; border-radius: 50%; background: #35b47a; box-shadow: 0 0 0 3px #dff6e9; }
|
||||||
|
.table-link { border: 0; padding: 0; color: #2875b6; background: transparent; font-size: .62rem; font-weight: 700; }
|
||||||
|
.table-link:hover { border: 0; background: transparent; text-decoration: underline; }
|
||||||
|
.device-list-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; color: #8ca1b2; font-size: .58rem; }
|
||||||
|
.device-list-footer .device-action { padding: 6px 8px; font-size: .59rem; }
|
||||||
|
.device-empty-inline { padding: 26px 10px; border: 1px dashed #c7d7e5; border-radius: 8px; text-align: center; background: white; }
|
||||||
|
.empty-art { display: grid; width: 38px; height: 38px; margin: 0 auto 10px; place-items: center; border-radius: 50%; color: #4c8bc0; background: #e9f4fc; font-size: 1.4rem; }
|
||||||
|
.device-empty-inline h4 { margin-bottom: 2px; }
|
||||||
|
.device-empty-inline p { margin: 0 0 12px; }
|
||||||
|
.device-form-card { display: grid; gap: 0; overflow: hidden; border: 1px solid #dbe6ef; border-radius: 9px; background: white; }
|
||||||
|
.device-form-section { padding: 15px; }
|
||||||
|
.device-form-section + .device-form-section { border-top: 1px solid #edf2f6; }
|
||||||
|
.device-form-section > p { margin: 0 0 12px; }
|
||||||
|
.device-form-section label { display: grid; gap: 5px; margin: 10px 0; color: #49637b; font-size: .63rem; font-weight: 700; }
|
||||||
|
.device-form-section em { color: #d0545d; font-style: normal; }
|
||||||
|
.device-form-section input, .device-form-section select { width: 100%; border: 1px solid #cfdde8; border-radius: 6px; padding: 8px 9px; outline: none; color: #24415d; background: #fbfdff; font-size: .68rem; }
|
||||||
|
.device-form-section input:focus, .device-form-section select:focus { border-color: #4b92c9; box-shadow: 0 0 0 3px #dceefb; }
|
||||||
|
.advanced-toggle { border: 0; padding: 0; color: #4681b3; background: transparent; font-size: .62rem; }
|
||||||
|
.advanced-toggle:hover { border: 0; background: transparent; text-decoration: underline; }
|
||||||
|
.advanced-note { margin-top: 8px; padding: 8px; border-radius: 5px; color: #657f96; background: #f0f6fb; font-size: .6rem; line-height: 1.5; }
|
||||||
|
.device-form-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 14px; }
|
||||||
|
.form-errors, .form-success { display: grid; gap: 4px; margin-top: 10px; padding: 9px 11px; border-radius: 6px; font-size: .61rem; line-height: 1.4; }
|
||||||
|
.form-errors { border: 1px solid #f3c7ca; color: #a2444d; background: #fff1f1; }
|
||||||
|
.form-success { border: 1px solid #bde6cd; color: #26774c; background: #f0fbf4; }
|
||||||
|
.form-errors strong { font-size: .64rem; }
|
||||||
|
.device-state-page { display: grid; justify-items: center; padding: 36px 20px; text-align: center; }
|
||||||
|
.device-state-page .device-subtitle { max-width: 265px; }
|
||||||
|
.state-orbit { display: grid; width: 66px; height: 66px; place-items: center; margin-bottom: 17px; border-radius: 50%; font-size: 1.7rem; font-weight: 800; }
|
||||||
|
.connecting-orbit { color: #2277b9; background: #e5f3ff; box-shadow: 0 0 0 9px #f0f8fe; }
|
||||||
|
.success-orbit { color: white; background: #31ad75; box-shadow: 0 0 0 9px #e4f7ed; }
|
||||||
|
.failed-orbit { color: #bd4650; background: #ffe9ea; box-shadow: 0 0 0 9px #fff3f3; }
|
||||||
|
.empty-orbit { color: #4b80ad; background: #e9f3fb; box-shadow: 0 0 0 9px #f5fafe; }
|
||||||
|
.success-text { color: #2c9a68 !important; }
|
||||||
|
.danger-text { color: #c44d55 !important; }
|
||||||
|
.connection-steps { display: grid; gap: 8px; width: 100%; max-width: 230px; margin: 5px 0 18px; padding: 11px 13px; border: 1px solid #dce7ef; border-radius: 7px; color: #9aabba; background: white; font-size: .62rem; text-align: left; }
|
||||||
|
.connection-steps .done { color: #2e9b6b; }
|
||||||
|
.connection-steps .active { color: #3379b4; font-weight: 700; }
|
||||||
|
.success-metrics { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; max-width: 260px; margin: 4px 0 18px; padding: 11px 0; border-top: 1px solid #e2ebf2; border-bottom: 1px solid #e2ebf2; }
|
||||||
|
.success-metrics span { display: grid; gap: 3px; border-left: 1px solid #e2ebf2; }
|
||||||
|
.success-metrics span:first-child { border-left: 0; }
|
||||||
|
.success-metrics b { color: #267d57; font-size: .68rem; }
|
||||||
|
.success-metrics small { color: #8da0b2; font-size: .55rem; }
|
||||||
|
.failure-reason { display: grid; gap: 5px; width: 100%; max-width: 270px; margin: 4px 0 17px; padding: 11px; border: 1px solid #f1c8cb; border-radius: 7px; color: #a54952; background: #fff5f5; text-align: left; }
|
||||||
|
.failure-reason strong { font-size: .63rem; }
|
||||||
|
.failure-reason span { font-size: .68rem; }
|
||||||
|
.failure-reason small { color: #bd7c82; font-size: .56rem; }
|
||||||
|
.device-detail-hero { display: flex; align-items: center; gap: 10px; padding: 14px; border: 1px solid #dbe6ef; border-radius: 8px; background: white; }
|
||||||
|
.device-detail-hero h3 { margin: 2px 0 2px; font-size: 1.08rem; }
|
||||||
|
.device-detail-hero .device-overline { font-size: .5rem !important; }
|
||||||
|
.online-badge { margin-left: auto; padding: 5px 8px; border-radius: 999px; color: #277b55; background: #e5f7ed; font-size: .61rem; font-weight: 700; }
|
||||||
|
.online-badge b { display: inline-block; width: 5px; height: 5px; margin: 0 4px 1px 0; border-radius: 50%; background: #36af77; }
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 13px 0; overflow: hidden; border: 1px solid #dbe6ef; border-radius: 8px; background: #dbe6ef; }
|
||||||
|
.detail-grid div { display: grid; gap: 5px; padding: 11px; background: white; }
|
||||||
|
.detail-grid span { color: #8a9eaf; font-size: .57rem; }
|
||||||
|
.detail-grid strong { overflow-wrap: anywhere; color: #2d4b67; font-size: .65rem; }
|
||||||
|
.device-detail-note { display: flex; gap: 8px; margin: 12px 0; padding: 9px; border-radius: 6px; color: #438164; background: #eef9f2; font-size: .61rem; }
|
||||||
|
.device-detail-note span { font-size: .9rem; }
|
||||||
|
.device-detail-note p { margin: 0; color: #5a866e; font-size: .6rem; }
|
||||||
|
.device-detail-note strong { color: #317353; }
|
||||||
|
@media (max-width: 520px) { .device-table { overflow-x: auto; } .device-table-row { min-width: 520px; } .device-page-heading { display: grid; } .device-page-heading .device-action { justify-self: start; } }
|
||||||
|
@media (max-width: 900px) { .studio-grid { display: block; height: auto; } .source-panel, .canvas-panel { display: none; } .preview-panel { display: block; min-height: 100vh; border: 0; padding: 16px; } }
|
||||||
|
@media (max-width: 620px) { .studio-grid { display: block; height: auto; } .source-panel { border: 0; border-bottom: 1px solid #24344c; } .canvas-panel { min-height: 520px; } }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createDeviceMock, EMPTY_DEVICE_DRAFT, reduceDeviceMock, validateDeviceDraft } from "../src/mocks/device-management.ts";
|
||||||
|
|
||||||
|
assert.deepEqual(validateDeviceDraft(EMPTY_DEVICE_DRAFT), ["设备名称为必填项", "主机地址为必填项"]);
|
||||||
|
|
||||||
|
const normal = createDeviceMock("normal");
|
||||||
|
const saved = reduceDeviceMock(normal, "device.save-config", { name: "新设备", host: "10.0.0.8" });
|
||||||
|
assert.equal(saved.ok, true);
|
||||||
|
const connected = reduceDeviceMock(saved.state, "device.resolve-connection");
|
||||||
|
assert.equal(connected.context.connectionOutcome, "success");
|
||||||
|
assert.equal(connected.state.devices.filter((device) => device.id === "device.mock-new").length, 1);
|
||||||
|
const connectedAgain = reduceDeviceMock(connected.state, "device.resolve-connection");
|
||||||
|
assert.equal(connectedAgain.state.devices.filter((device) => device.id === "device.mock-new").length, 1);
|
||||||
|
|
||||||
|
const retrySuccess = reduceDeviceMock(createDeviceMock("retry-success"), "device.save-config", { name: "重试设备", host: "10.0.0.9" });
|
||||||
|
const firstFailure = reduceDeviceMock(retrySuccess.state, "device.resolve-connection");
|
||||||
|
assert.equal(firstFailure.context.connectionOutcome, "failure");
|
||||||
|
assert.match(String(firstFailure.context.connectionReason), /超时/);
|
||||||
|
const retry = reduceDeviceMock(firstFailure.state, "device.retry-connection");
|
||||||
|
const secondSuccess = reduceDeviceMock(retry.state, "device.resolve-connection");
|
||||||
|
assert.equal(secondSuccess.context.connectionOutcome, "success");
|
||||||
|
|
||||||
|
const retryFailure = reduceDeviceMock(createDeviceMock("retry-failure"), "device.save-config", { name: "持续失败设备", host: "10.0.0.10" });
|
||||||
|
const failureOne = reduceDeviceMock(retryFailure.state, "device.resolve-connection");
|
||||||
|
const failureTwo = reduceDeviceMock(reduceDeviceMock(failureOne.state, "device.retry-connection").state, "device.resolve-connection");
|
||||||
|
assert.equal(failureTwo.context.connectionOutcome, "failure");
|
||||||
|
assert.equal(failureTwo.state.devices.length, 1);
|
||||||
|
|
||||||
|
console.log("T4 deterministic device mock tests passed (required fields, success, empty, retry success/failure, dedupe)");
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function openDeviceManagement(page: Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId("flow-node-page.studio-placeholder")).toBeAttached();
|
||||||
|
await page.getByTestId("dispatch-device.open-management").click();
|
||||||
|
await expect(page.getByTestId("device-page-list")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillDevice(page: Page) {
|
||||||
|
await page.getByTestId("device-name").fill("Playwright 设备");
|
||||||
|
await page.getByTestId("device-host").fill("10.0.0.8");
|
||||||
|
await page.getByTestId("device-action-device.continue-to-config").click();
|
||||||
|
await expect(page.getByTestId("device-page-config")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("device journey: list → create → required error → config → connecting → success → detail → list", async ({ page }) => {
|
||||||
|
await openDeviceManagement(page);
|
||||||
|
await page.getByTestId("device-action-device.create").first().click();
|
||||||
|
await expect(page.getByTestId("device-page-create")).toBeVisible();
|
||||||
|
await page.getByTestId("device-action-device.continue-to-config").click();
|
||||||
|
await expect(page.getByTestId("device-form-errors")).toContainText("设备名称为必填项");
|
||||||
|
await fillDevice(page);
|
||||||
|
await page.getByTestId("device-action-device.save-config").click();
|
||||||
|
await expect(page.getByTestId("device-page-connecting")).toBeVisible();
|
||||||
|
await page.getByTestId("device-action-device.resolve-connection").click();
|
||||||
|
await expect(page.getByTestId("device-page-connected")).toBeVisible();
|
||||||
|
await page.getByTestId("device-action-device.open-detail").click();
|
||||||
|
await expect(page.getByTestId("device-page-detail")).toBeVisible();
|
||||||
|
await page.getByTestId("device-action-device.back-to-list").click();
|
||||||
|
await expect(page.getByTestId("device-page-list")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("device-id-device.mock-new")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("device journey: deterministic failure → retry → success without duplicate device", async ({ page }) => {
|
||||||
|
await openDeviceManagement(page);
|
||||||
|
await page.getByTestId("device-scenario").selectOption("retry-success", { force: true });
|
||||||
|
await page.getByTestId("device-action-device.create").first().click();
|
||||||
|
await fillDevice(page);
|
||||||
|
await page.getByTestId("device-action-device.save-config").click();
|
||||||
|
await page.getByTestId("device-action-device.resolve-connection").click();
|
||||||
|
await expect(page.getByTestId("device-page-failed")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("device-page-failed")).toContainText("连接超时");
|
||||||
|
await page.getByTestId("device-action-device.retry-connection").click();
|
||||||
|
await page.getByTestId("device-action-device.resolve-connection").click();
|
||||||
|
await expect(page.getByTestId("device-page-connected")).toBeVisible();
|
||||||
|
await page.getByTestId("device-action-device.open-detail").click();
|
||||||
|
await expect(page.getByTestId("device-page-detail")).toBeVisible();
|
||||||
|
await page.getByTestId("device-action-device.back-to-list").click();
|
||||||
|
await expect(page.getByTestId("device-id-device.mock-new")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("device empty state is visible and can start creation", async ({ page }) => {
|
||||||
|
await openDeviceManagement(page);
|
||||||
|
await page.getByTestId("device-scenario").selectOption("empty", { force: true });
|
||||||
|
await expect(page.getByText("还没有设备")).toBeVisible();
|
||||||
|
await page.getByTestId("device-action-device.create").first().click();
|
||||||
|
await expect(page.getByTestId("device-page-create")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Studio loads the real flow canvas at desktop/tablet/mobile viewports", async ({ page }) => {
|
||||||
|
for (const viewport of [{ name: "desktop", width: 1440, height: 900 }, { name: "tablet", width: 1024, height: 900 }, { name: "mobile", width: 390, height: 844 }]) {
|
||||||
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId("flow-node-page.studio-placeholder")).toBeAttached();
|
||||||
|
await expect(page.getByTestId("runtime-status")).toContainText("Ready");
|
||||||
|
}
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user