| @@ -0,0 +1,21 @@ | |||||
| BASE_URL=http://127.0.0.1:6180 | |||||
| E2E_ADMIN_USERNAME= | |||||
| E2E_ADMIN_PASSWORD= | |||||
| # 行政、教员真实登录的组织名称。教员还需配置上级教学系;最终部门可写教研室, | |||||
| # 也可与上级同名以选择“本级”。管理员、学员不读取这两个变量。 | |||||
| E2E_LOGIN_PARENT_DEPARTMENT_NAME= | |||||
| E2E_LOGIN_DEPARTMENT_NAME= | |||||
| # 可选。默认读取相邻 unreal_tran_web/public/models/excavator-a.glb。 | |||||
| E2E_EXCAVATOR_GLB= | |||||
| # 强制改密用例必须使用可重复创建的一次性独立账号;未配置时该用例自动跳过。 | |||||
| E2E_FORCE_CHANGE_USERNAME= | |||||
| E2E_FORCE_CHANGE_PASSWORD= | |||||
| E2E_FORCE_CHANGE_NEW_PASSWORD= | |||||
| E2E_FORCE_CHANGE_ROLE_CODE=teacher | |||||
| # 可选。教学实施真实环境只读联调使用的短期访问令牌;不配置时 teaching-live.spec.ts 自动跳过。 | |||||
| # 真实值只能放入本机 .env 或进程环境,禁止写入版本库和测试报告。 | |||||
| E2E_LIVE_ACCESS_TOKEN= | |||||
| @@ -0,0 +1,12 @@ | |||||
| node_modules/ | |||||
| .env | |||||
| .env.* | |||||
| !.env.example | |||||
| artifacts/html-report/ | |||||
| artifacts/test-results/ | |||||
| artifacts/results.json | |||||
| artifacts/screenshots/*.png | |||||
| !artifacts/screenshots/.gitkeep | |||||
| # 内容制作用例的截图与生成的交付物(.ofd / 离线 HTML) | |||||
| artifacts/content-guide-lifecycle/ | |||||
| artifacts/content-training-studio/ | |||||
| @@ -0,0 +1,126 @@ | |||||
| # Unreal Tran E2E | |||||
| 本目录是独立的 Playwright E2E 工程,不参与前后端构建。测试默认访问 | |||||
| `http://127.0.0.1:6180`,也可通过 `BASE_URL` 指向局域网地址或其它环境。 | |||||
| ## 环境变量 | |||||
| 工程内不提供默认密码。可以通过进程环境变量传入管理员凭据,也可以从 | |||||
| `.env.example` 复制本地 `.env`;Playwright 启动时会读取该文件,但不会覆盖已经设置的进程变量: | |||||
| ```powershell | |||||
| $env:BASE_URL='http://127.0.0.1:6180' | |||||
| $env:E2E_ADMIN_USERNAME='admin' | |||||
| $env:E2E_ADMIN_PASSWORD='<通过安全渠道取得>' | |||||
| ``` | |||||
| ```powershell | |||||
| Copy-Item .env.example .env | |||||
| # 只在本机编辑 .env;该文件已被 .gitignore 排除。 | |||||
| ``` | |||||
| 强制改密属于有状态、一次性的条件用例,必须使用可重复创建的独立账号;未配置时自动跳过: | |||||
| ```powershell | |||||
| $env:E2E_FORCE_CHANGE_USERNAME='e2e_force_change' | |||||
| $env:E2E_FORCE_CHANGE_PASSWORD='<一次性旧密码>' | |||||
| $env:E2E_FORCE_CHANGE_NEW_PASSWORD='<本轮新密码>' | |||||
| $env:E2E_FORCE_CHANGE_ROLE_CODE='teacher' | |||||
| ``` | |||||
| 不要把真实值写进 `.env.example`、命令历史、测试报告或版本库。 | |||||
| ## 安装与执行 | |||||
| 先启动 6100/6101/6102 服务和 6180 前端,再在本目录执行: | |||||
| ```powershell | |||||
| mise install | |||||
| pnpm install --frozen-lockfile | |||||
| pnpm run install:browsers | |||||
| pnpm test | |||||
| ``` | |||||
| 完整业务 E2E 只在四个服务就绪且提供本机测试凭据后运行;缺少凭据时仍可执行类型检查与无登录烟测。常用命令: | |||||
| - `pnpm test`:无头执行 Chromium。 | |||||
| - `pnpm run test:headed`:显示浏览器执行。 | |||||
| - `pnpm run test:ui`:使用 Playwright UI。 | |||||
| - `pnpm run typecheck`:只检查 E2E TypeScript,不启动浏览器或服务。 | |||||
| - `pnpm run report`:打开上次 HTML 报告。 | |||||
| 三维编辑器用例默认读取同一工作区中的 | |||||
| `../unreal_tran_web/public/models/excavator-a.glb`。若测试资源位于其它位置,可通过 | |||||
| `E2E_EXCAVATOR_GLB` 指定;不要把带凭据的网络地址写入该变量。 | |||||
| ## 报告与制品 | |||||
| - HTML 报告:`artifacts/html-report` | |||||
| - JSON 报告:`artifacts/results.json` | |||||
| - 成功与失败截图:`artifacts/screenshots` | |||||
| - trace、video 与 Playwright 原始结果:`artifacts/test-results` | |||||
| 成功截图以项目、测试文件、用例标题和检查点稳定命名;每个用例/重试开始时先清理自己的旧成功图,再生成新图并附加到 HTML/JSON 报告。截图覆盖登录、三种导航、三种主题、侧栏收展、七个系统页、菜单草稿操作、内容导航以及 CRUD 各成功节点。失败截图额外带 retry 次数和时间戳,trace 与 video 只在失败时保留。 | |||||
| ## 测试分层说明 | |||||
| 教学实施模块采用三层验证,三者不能互相替代: | |||||
| - Java 策略与服务测试负责验证后端授权、状态机、正式考核时间窗、岗位约束、故障私密字段脱敏和服务端派生进度。 | |||||
| - `tests/teaching-module.spec.ts` 是浏览器端的状态化 Mock 契约测试。Mock 会按最终 DTO 校验请求、维护运行版本与状态并返回服务端权威响应,用来验证 UI 分支和前端请求契约;它不连接 KingBase,也不等同于真实 API 联调。 | |||||
| - 测试环境部署后只做 IP + 端口烟测,确认 Nginx、网关、鉴权和主要页面可访问。烟测通过不代表完整业务状态机已在真实环境回归。 | |||||
| 教学 UI 契约可单独执行: | |||||
| ```powershell | |||||
| npm run typecheck | |||||
| npm test -- teaching-module.spec.ts | |||||
| ``` | |||||
| 教学实施真实环境只读联调与状态化 Mock 是两类测试:`teaching-module.spec.ts` 拦截 API,用于稳定验证 12 条前端状态机闭环,但不代表 Java、Redis 或 KingBase 联调;`teaching-live.spec.ts` 不拦截 API,只读取测试环境中保留的 `UAT-` 教学数据。真实联调用短期访问令牌注入 `sessionStorage`,测试文件已关闭 trace/video,且不会提交业务写请求或清理 UAT 数据。未配置令牌时自动跳过: | |||||
| ```powershell | |||||
| $env:BASE_URL='http://<测试环境IP>:6180' | |||||
| $env:E2E_LIVE_ACCESS_TOKEN='<短期访问令牌>' | |||||
| npm test -- teaching-live.spec.ts | |||||
| ``` | |||||
| 不要把令牌写入命令历史、截图、HTML/JSON 报告或版本库;执行完成后应按测试环境会话管理策略撤销令牌。 | |||||
| 内容制作的作业指导书(OFD)走同一套状态化 Mock 分层:`tests/content-guide-lifecycle.spec.ts` 按 | |||||
| `wiki/11-内容制作模块.md` 的契约打桩内容制作接口,覆盖编制 → 送审 → 审批时间线 → 预览 → 预检 → | |||||
| 批准 → 发布 → 交付物下载,并解包核对生成的 `.ofd` 与离线 HTML。Mock 的状态流转表与服务端 | |||||
| `REVIEW_TRANSITIONS` 同源,契约分叉会直接失败;它同样不代表 KingBase 联调。截图与生成的交付物落在 | |||||
| `artifacts/content-guide-lifecycle/`(已忽略提交): | |||||
| 训练编排走同一层:`tests/content-training-studio.spec.ts` 覆盖 8 个流程视图、绑定场景的目标解析、 | |||||
| 步骤编排与保存产出的运行时编码、实装进度递增与对抗岗位校验。截图落在 | |||||
| `artifacts/content-training-studio/`(已忽略提交)。 | |||||
| ```powershell | |||||
| npm test -- content-guide-lifecycle.spec.ts | |||||
| npm test -- content-training-studio.spec.ts | |||||
| ``` | |||||
| ## 用例范围 | |||||
| - 管理员登录与退出。 | |||||
| - 四身份展示顺序、默认管理员、组织级联选择与密码 eye 图标。 | |||||
| - 精简平台页头、账号菜单和个人中心资料/修改密码契约。 | |||||
| - 独立账号强制改密(条件执行)。 | |||||
| - 三种导航模式、明亮/暗黑/跟随系统主题及刷新后持久化。 | |||||
| - 用户、部门、角色、菜单、权限、审计、接口配置七个页面加载。 | |||||
| - 菜单搜索、布局预览、编辑与同级排序草稿撤销;用例不调用菜单写接口。 | |||||
| - 内容制作四类新名称、训练三模式及编辑/预览子路由活动态。 | |||||
| - 教学实施六个侧栏入口与内部作业指导书、纯行政只读聚合、UNION 行政+学员本人运行、COMMON 领取到评定闭环。 | |||||
| - 实装训练两步 `physical-events`、对抗岗位动作/负责人提交/公开故障/分队编组干预、正式考核 Unix 秒和辅助能力硬锁。 | |||||
| - 运行场景与沉浸交互必须请求教学资产清单并消费发布 `definitionSnapshot`;WebXR 测试不会用“只有 canvas”作为通过条件。 | |||||
| - 数据中心筛选、分页安全明细和详情抽屉;个人检查点、答案及私密载荷不得进入页面。 | |||||
| - 装备模型编辑器和数字车间场景编辑器均为原生 Three.js canvas,断言页面不存在 iframe。 | |||||
| - 真实 `excavator-a.glb` 上传进度、SHA-256 内容寻址、READY 资产、服务端文件元数据及刷新恢复。 | |||||
| - 模型选择/变换/软删除恢复、时间轴、蓝图、保存发布;真实车间部件软删除恢复、发布模型目录引用、放置变换、精确版本依赖及场景发布。 | |||||
| - 使用 `UTE2E-<唯一值>` 前缀创建部门、角色和用户,完成编辑、搜索、角色授权、审计验证,并在 `finally` 中按用户 → 角色 → 部门顺序清理。 | |||||
| 测试配置为单 worker 串行执行,避免管理数据之间相互污染。三维编辑器用例在 | |||||
| `finally` 中按场景 → 模型顺序调用 API 清理,并始终附加中文清理报告;若服务异常导致清理失败,可在内容制作页面按 `UTE2E-` 搜索并人工清理残留。静态测试设计和待执行状态见 | |||||
| [`reports/三维编辑器E2E测试报告.md`](reports/三维编辑器E2E测试报告.md)。 | |||||
| @@ -0,0 +1 @@ | |||||
| @@ -0,0 +1,68 @@ | |||||
| # Unreal Tran 内容制作模块测试报告 | |||||
| - 执行时间:2026-08-09 10:46(Asia/Shanghai) | |||||
| - 测试地址:`http://127.0.0.1:6180` | |||||
| - 浏览器:Playwright Chromium | |||||
| - 用例文件:`tests/content-authoring.spec.ts` | |||||
| - 测试数据前缀:`UTE2E-CONTENT-<唯一值>` / `E2E_MODEL_<唯一值>` | |||||
| ## 1. 结果摘要 | |||||
| | 指标 | 结果 | | |||||
| |---|---:| | |||||
| | 用例总数 | 1 | | |||||
| | 通过 | 1 | | |||||
| | 失败 | 0 | | |||||
| | Playwright 用时 | 35.26 秒 | | |||||
| | 业务测试数据清理 | 成功 | | |||||
| | 临时认证夹具清理 | 成功 | | |||||
| | 活动残留数据 | 0 | | |||||
| ## 2. 覆盖范围 | |||||
| | 测试阶段 | 验证点 | 结果 | | |||||
| |---|---|---| | |||||
| | 真实登录 | 选择管理员角色,通过网关调用认证服务进入平台 | 通过 | | |||||
| | 四类内容列表 | 模型、场景、训练项目、作业指导书从真实 API 加载 | 通过 | | |||||
| | 搜索与重置 | 四类列表按关键字搜索无结果并恢复完整列表 | 通过 | | |||||
| | 明暗主题 | 四类明亮列表、作业指导书暗黑列表可读 | 通过 | | |||||
| | 模型新建 | 唯一编码、名称、分类、说明创建成功 | 通过 | | |||||
| | 动态路由导航 | 编辑路由下内容制作一级菜单、模型管理二级菜单和面包屑保持正确 | 通过 | | |||||
| | 结构化编辑 | 模型资源 URI、节点数、材质数、动画数保存 | 通过 | | |||||
| | 资产元数据 | 资产编码、名称、URI、MIME、大小、SHA-256 和 JSON 元数据保存 | 通过 | | |||||
| | 版本查看 | 版本列表可见,版本详情包含结构化内容和资产 | 通过 | | |||||
| | 返回与检索 | 返回列表后按唯一编码检索到测试模型 | 通过 | | |||||
| | 数据清理 | `finally` 删除测试模型,数据库复核无活动项目残留 | 通过 | | |||||
| ## 3. 构建与自动化校验 | |||||
| - 后端:`mvn -q -pl ut_tran -am package`,Java 17,19 个测试全部通过。 | |||||
| - 前端:`pnpm run build`,`vue-tsc --noEmit` 与 Vite 生产构建通过。 | |||||
| - E2E 类型检查:`pnpm run typecheck` 通过。 | |||||
| - E2E:`pnpm exec playwright test tests/content-authoring.spec.ts`,1 通过、0 失败。 | |||||
| - 数据库残留复核:临时用户、会话、凭证、角色关系和 `E2E_MODEL_%` 活动项目均为 0。 | |||||
| ## 4. 截图与报告制品 | |||||
| 以下 9 张成功截图已保存到 `artifacts/screenshots/`,并附加在 HTML/JSON 报告中: | |||||
| - `content-models-light` | |||||
| - `content-scenes-light` | |||||
| - `content-training-light` | |||||
| - `content-guides-light` | |||||
| - `content-guides-dark` | |||||
| - `content-model-editor-route-active` | |||||
| - `content-model-version-previewed` | |||||
| - `content-model-searched` | |||||
| - `content-model-cleaned` | |||||
| 其他制品: | |||||
| - HTML 报告:`artifacts/html-report/index.html` | |||||
| - JSON 结果:`artifacts/results.json` | |||||
| - Playwright 附件:`artifacts/test-results/` | |||||
| ## 5. 非阻断提示 | |||||
| - Vite 主包仍有大于 500 kB 的构建告警,不影响本轮功能;后续可按页面继续拆包。 | |||||
| - 当前列表响应已只返回轻量 `contentSummary`,但服务端仍需从当前版本工程 JSON 生成摘要。正式导入超大 Three.js 工程前,建议增加独立摘要字段或摘要表,降低列表查询的数据库 I/O。 | |||||
| @@ -0,0 +1,61 @@ | |||||
| # Unreal Tran 内容制作模块 E2E 测试报告 | |||||
| - 执行时间:`待填写(Asia/Shanghai)` | |||||
| - 测试地址:`待填写,不记录账号或密码` | |||||
| - 浏览器:Playwright Chromium | |||||
| - 用例文件:`tests/content-authoring.spec.ts` | |||||
| - 测试数据前缀:`UTE2E-CONTENT-<唯一值>` | |||||
| ## 1. 结果摘要 | |||||
| | 指标 | 结果 | | |||||
| |---|---:| | |||||
| | 用例总数 | 待填写 | | |||||
| | 通过 | 待填写 | | |||||
| | 失败 | 待填写 | | |||||
| | 清理成功 | 待填写 | | |||||
| | 残留数据 | 待填写(无 / 项目编码) | | |||||
| ## 2. 覆盖范围 | |||||
| | 测试阶段 | 验证点 | 结果 | | |||||
| |---|---|---| | |||||
| | 管理员登录 | 选择管理员角色并进入平台 | 待填写 | | |||||
| | 四类内容列表 | 模型、场景、训练项目、作业指导书加载 | 待填写 | | |||||
| | 搜索与重置 | 每类列表按唯一无匹配关键字搜索,再恢复完整列表 | 待填写 | | |||||
| | 主题 | 明亮主题四类列表截图;暗黑主题内容列表截图 | 待填写 | | |||||
| | 模型新建 | 唯一编码、名称、分类、说明创建成功 | 待填写 | | |||||
| | 动态路由导航 | `/content/models/:id/edit` 下内容制作一级菜单、模型管理二级菜单保持活动态 | 待填写 | | |||||
| | 结构化编辑 | 模型 URI、节点数、材质数、动画数保存 | 待填写 | | |||||
| | 资产元数据 | 一条模型资产的编码、名称、URI、MIME、大小、SHA-256、JSON 元数据保存 | 待填写 | | |||||
| | 版本查看 | 版本列表可见,版本抽屉包含结构化内容和资产 | 待填写 | | |||||
| | 返回与检索 | 返回列表后按唯一编码找到测试模型 | 待填写 | | |||||
| | 数据清理 | `finally` 删除测试模型;失败附件记录清理状态与可能残留 | 待填写 | | |||||
| ## 3. 校验结果 | |||||
| - E2E TypeScript:`pnpm run typecheck` — 待填写。 | |||||
| - Playwright 执行:`pnpm exec playwright test tests/content-authoring.spec.ts` — 待填写。 | |||||
| - 服务启停:本测试不负责启动或停止 6100/6101/6102/6180 服务。 | |||||
| ## 4. 截图清单 | |||||
| 成功截图由 `captureScreenshot` 保存到 `artifacts/screenshots/` 并附加到 HTML/JSON 报告,预期包含: | |||||
| - `content-models-light` | |||||
| - `content-scenes-light` | |||||
| - `content-training-light` | |||||
| - `content-guides-light` | |||||
| - `content-guides-dark` | |||||
| - `content-model-editor-route-active` | |||||
| - `content-model-version-previewed` | |||||
| - `content-model-searched` | |||||
| - `content-model-cleaned` | |||||
| ## 5. 清理与残留 | |||||
| - 清理状态:`待填写(removed / not-found / residual)` | |||||
| - 残留项目编码:`无;若失败,填写 UTE2E-CONTENT-* 对应编码` | |||||
| - 人工清理说明:进入“内容制作 → 模型管理”,按报告中的项目编码搜索并删除。 | |||||
| 失败时,Playwright 报告附件“内容制作测试数据清理结果”会记录项目 ID、唯一编码和清理异常;附件不包含登录凭据。 | |||||
| @@ -0,0 +1,40 @@ | |||||
| # Unreal Tran 系统表格与分页 E2E 测试报告 | |||||
| - 执行时间:2026-08-08 20:00(Asia/Shanghai) | |||||
| - 测试地址:`http://127.0.0.1:6180` | |||||
| - 浏览器:Playwright Chromium | |||||
| - 数据来源:页面 API Mock(不写入业务数据库) | |||||
| ## 1. 结果摘要 | |||||
| | 指标 | 结果 | | |||||
| |---|---:| | |||||
| | 用例总数 | 2 | | |||||
| | 通过 | 2 | | |||||
| | 失败 | 0 | | |||||
| ## 2. 覆盖范围 | |||||
| | 用例 | 状态 | 主要覆盖 | | |||||
| |---|---|---| | |||||
| | 用户列表全局中文并支持部门树选择与再次点击取消 | 通过 | 部门筛选与人员列表独立卡片、部门及下级筛选、再次点击清除、中文分页、总数、页大小选择、跳转、表格边框和斑马线、正文与提示字号下限、启用/停用按钮颜色 | | |||||
| | 审计表格与分页遵循系统统一规范 | 通过 | 审计表格边框和斑马线、总数、页大小选择、页码与跳转组件 | | |||||
| ## 3. 构建与单元测试 | |||||
| - 前端 `pnpm run build`:`vue-tsc --noEmit` 与 Vite 构建通过。 | |||||
| - E2E `pnpm run typecheck`:通过。 | |||||
| - Java 17 `mvn -pl ut_auth -am test`:8 通过、0 失败。 | |||||
| - Excel 导出单测验证:XLSX 文件可读取,首行冻结,首行自动筛选,表头使用平台主题色,内容区域四向细边框,用户显示名格式正确。 | |||||
| ## 4. 制品 | |||||
| - [HTML 测试报告](./html-report/index.html) | |||||
| - [JSON 测试结果](./results.json) | |||||
| - [测试截图目录](./screenshots/) | |||||
| - [失败 trace/video 目录](./test-results/) | |||||
| ## 5. 非阻断提示 | |||||
| - Vite 构建仍提示主 chunk 大于 500 kB,不影响本轮功能。 | |||||
| - 当前浏览器中的前台 API 进程需要由开发者重新启动后,才会加载新的 XLSX 导出实现;本轮未停止或替换用户正在查看日志的进程。 | |||||
| @@ -0,0 +1,72 @@ | |||||
| # Unreal Tran 菜单与内容升级测试报告 | |||||
| - 执行时间:2026-08-09 12:17(Asia/Shanghai) | |||||
| - 测试地址:`http://127.0.0.1:6180` | |||||
| - 局域网地址:`http://192.168.31.168:6180` | |||||
| - 浏览器:Playwright Chromium 1.55.0 | |||||
| - 执行方式:单 Worker 串行,使用一次性管理员夹具 | |||||
| ## 1. 结果摘要 | |||||
| | 指标 | 结果 | | |||||
| |---|---:| | |||||
| | 自动化用例 | 10 | | |||||
| | 通过 | 9 | | |||||
| | 失败 | 0 | | |||||
| | 跳过 | 1 | | |||||
| | Playwright 用时 | 97.91 秒 | | |||||
| | 临时管理员清理 | 成功 | | |||||
| | 菜单配置恢复 | 成功 | | |||||
| 跳过项为“首次登录强制改密”。该用例要求独立、可修改密码且不能复用管理员的账号,本轮未提供这组专用凭据,因此按测试约定跳过,不影响菜单与内容制作验收。 | |||||
| ## 2. 覆盖范围 | |||||
| | 模块 | 验证内容 | 结果 | | |||||
| |---|---|---| | |||||
| | 登录 | 四角色入口、管理员真实登录、错误角色拦截、安全退出 | 通过 | | |||||
| | 导航 | 顶部一级+左侧二级、纯左侧、顶部下拉三种模式及持久化 | 通过 | | |||||
| | 侧栏 | 展开/收起、刷新后保持、二级菜单可达 | 通过 | | |||||
| | 主题 | 明亮、暗黑、跟随系统;暗黑文字和边界可读 | 通过 | | |||||
| | 菜单管理 | 树表加载、搜索/重置、三布局预览、编辑草稿、同级排序、撤销 | 通过 | | |||||
| | 菜单 API | 原子保存、乐观锁版本、保存后读取、按原始快照恢复 | 通过 | | |||||
| | 内容制作 | 模型制作、场景制作、训练编排、作业指导四类列表及真实 API | 通过 | | |||||
| | 训练编排 | 虚拟仿真、实装实训、对抗训练三形态统计、切换、卡片与封面回退 | 通过 | | |||||
| | 内容工作流 | 新建、搜索、结构化编辑、资产、版本、子路由活动态、删除清理 | 通过 | | |||||
| | 系统管理 | 用户、部门、角色、菜单、权限、审计、接口配置七页加载 | 通过 | | |||||
| | 系统 CRUD | 部门/角色/用户创建编辑、授权、审计检索及清理 | 通过 | | |||||
| | 统一规范 | 部门树筛选、中文分页、表格边框与斑马线、搜索与重置 | 通过 | | |||||
| ## 3. 构建与服务验证 | |||||
| - Java 17:API 根项目 `mvn -q clean package` 通过。 | |||||
| - `ut_auth`:18 项测试通过。 | |||||
| - `ut_tran`:22 项测试通过。 | |||||
| - Web:`pnpm run build` 通过,包含 `vue-tsc --noEmit` 与 Vite 生产构建。 | |||||
| - E2E:`pnpm run typecheck` 通过。 | |||||
| - 端口健康:6100、6101、6102、6180 均返回 HTTP 200。 | |||||
| - 局域网访问:`192.168.31.168:6180` 返回 HTTP 200。 | |||||
| ## 4. 数据库验证 | |||||
| - 已执行 `20260809_003_menu_management.sql`。 | |||||
| - 目标:KingbaseES V008R006C009B0014,`public` schema。 | |||||
| - 迁移后菜单权限 2/2、导航节点 29/29。 | |||||
| - 执行前已备份 `ut_sys_permission` 与 `ut_sys_role_permission`。 | |||||
| - 测试期间创建的临时管理员、会话、凭证和角色关系已在 `finally` 中物理清理。 | |||||
| - 菜单真实保存测试在 `finally` 中恢复执行前完整名称与顺序,不使用“恢复默认”覆盖既有配置。 | |||||
| ## 5. 制品 | |||||
| - [HTML 测试报告](./html-report/index.html) | |||||
| - [JSON 测试结果](./results.json) | |||||
| - [全部成功截图](./screenshots/) | |||||
| - [菜单管理页面](./screenshots/chromium--system-pages.spec--系统管理七个页面均可加载--system-menus.png) | |||||
| - [侧栏收起](./screenshots/chromium--preferences.spec--三种导航模式与三种主题可以持久化--sidebar-collapsed.png) | |||||
| - [暗黑主题](./screenshots/chromium--preferences.spec--三种导航模式与三种主题可以持久化--theme-dark.png) | |||||
| - [训练三形态](./screenshots/chromium--menu-content-navigation.spec--菜单草稿操作不落库且新版内容导航与子路由活动态正确--content-training-three-modes.png) | |||||
| ## 6. 非阻断说明 | |||||
| - Vite 仍提示主 chunk 大于 500 kB;不影响本轮功能,后续可按编辑器/Element Plus 能力拆包。 | |||||
| - 当前“下载”明确输出工程 JSON,不宣称为正式 OFD 文件;真实 OFD 排版/签章/导出引擎应作为后续独立能力建设。 | |||||
| @@ -0,0 +1,2 @@ | |||||
| [tools] | |||||
| node = "22.19.0" | |||||
| @@ -0,0 +1,111 @@ | |||||
| { | |||||
| "name": "unreal-tran-e2e", | |||||
| "version": "0.1.0", | |||||
| "lockfileVersion": 3, | |||||
| "requires": true, | |||||
| "packages": { | |||||
| "": { | |||||
| "name": "unreal-tran-e2e", | |||||
| "version": "0.1.0", | |||||
| "devDependencies": { | |||||
| "@playwright/test": "1.55.0", | |||||
| "@types/node": "22.18.1", | |||||
| "typescript": "5.9.3" | |||||
| } | |||||
| }, | |||||
| "node_modules/@playwright/test": { | |||||
| "version": "1.55.0", | |||||
| "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.55.0.tgz", | |||||
| "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", | |||||
| "dev": true, | |||||
| "license": "Apache-2.0", | |||||
| "dependencies": { | |||||
| "playwright": "1.55.0" | |||||
| }, | |||||
| "bin": { | |||||
| "playwright": "cli.js" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=18" | |||||
| } | |||||
| }, | |||||
| "node_modules/@types/node": { | |||||
| "version": "22.18.1", | |||||
| "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.18.1.tgz", | |||||
| "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==", | |||||
| "dev": true, | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "undici-types": "~6.21.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/fsevents": { | |||||
| "version": "2.3.2", | |||||
| "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", | |||||
| "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", | |||||
| "dev": true, | |||||
| "hasInstallScript": true, | |||||
| "license": "MIT", | |||||
| "optional": true, | |||||
| "os": [ | |||||
| "darwin" | |||||
| ], | |||||
| "engines": { | |||||
| "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/playwright": { | |||||
| "version": "1.55.0", | |||||
| "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.55.0.tgz", | |||||
| "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", | |||||
| "dev": true, | |||||
| "license": "Apache-2.0", | |||||
| "dependencies": { | |||||
| "playwright-core": "1.55.0" | |||||
| }, | |||||
| "bin": { | |||||
| "playwright": "cli.js" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=18" | |||||
| }, | |||||
| "optionalDependencies": { | |||||
| "fsevents": "2.3.2" | |||||
| } | |||||
| }, | |||||
| "node_modules/playwright-core": { | |||||
| "version": "1.55.0", | |||||
| "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.55.0.tgz", | |||||
| "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", | |||||
| "dev": true, | |||||
| "license": "Apache-2.0", | |||||
| "bin": { | |||||
| "playwright-core": "cli.js" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=18" | |||||
| } | |||||
| }, | |||||
| "node_modules/typescript": { | |||||
| "version": "5.9.3", | |||||
| "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", | |||||
| "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", | |||||
| "dev": true, | |||||
| "license": "Apache-2.0", | |||||
| "bin": { | |||||
| "tsc": "bin/tsc", | |||||
| "tsserver": "bin/tsserver" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=14.17" | |||||
| } | |||||
| }, | |||||
| "node_modules/undici-types": { | |||||
| "version": "6.21.0", | |||||
| "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", | |||||
| "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", | |||||
| "dev": true, | |||||
| "license": "MIT" | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| { | |||||
| "name": "unreal-tran-e2e", | |||||
| "private": true, | |||||
| "version": "0.1.0", | |||||
| "type": "module", | |||||
| "scripts": { | |||||
| "install:browsers": "playwright install chromium", | |||||
| "test": "playwright test", | |||||
| "test:headed": "playwright test --headed", | |||||
| "test:ui": "playwright test --ui", | |||||
| "typecheck": "tsc --noEmit", | |||||
| "report": "playwright show-report artifacts/html-report" | |||||
| }, | |||||
| "devDependencies": { | |||||
| "@types/node": "22.18.1", | |||||
| "@playwright/test": "1.55.0", | |||||
| "typescript": "5.9.3" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,53 @@ | |||||
| import { defineConfig, devices } from '@playwright/test' | |||||
| import fs from 'node:fs' | |||||
| import path from 'node:path' | |||||
| const loadLocalEnvironment = () => { | |||||
| const filename = process.env.E2E_ENV_FILE?.trim() || path.resolve('.env') | |||||
| if (!fs.existsSync(filename)) return | |||||
| for (const line of fs.readFileSync(filename, 'utf8').split(/\r?\n/)) { | |||||
| const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/) | |||||
| if (!match || match[1]!.startsWith('#') || process.env[match[1]!]) continue | |||||
| const raw = match[2]! | |||||
| process.env[match[1]!] = raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) | |||||
| ? raw.slice(1, -1) | |||||
| : raw | |||||
| } | |||||
| } | |||||
| loadLocalEnvironment() | |||||
| const baseURL = (process.env.BASE_URL ?? 'http://127.0.0.1:6180').replace(/\/$/, '') | |||||
| export default defineConfig({ | |||||
| testDir: './tests', | |||||
| outputDir: './artifacts/test-results', | |||||
| fullyParallel: false, | |||||
| workers: 1, | |||||
| retries: process.env.CI ? 1 : 0, | |||||
| timeout: 60_000, | |||||
| expect: { | |||||
| timeout: 10_000, | |||||
| }, | |||||
| reporter: [ | |||||
| ['list'], | |||||
| ['html', { outputFolder: 'artifacts/html-report', open: 'never' }], | |||||
| ['json', { outputFile: 'artifacts/results.json' }], | |||||
| ], | |||||
| use: { | |||||
| baseURL, | |||||
| locale: 'zh-CN', | |||||
| timezoneId: 'Asia/Shanghai', | |||||
| actionTimeout: 10_000, | |||||
| navigationTimeout: 20_000, | |||||
| screenshot: 'off', | |||||
| trace: 'retain-on-failure', | |||||
| video: 'retain-on-failure', | |||||
| }, | |||||
| projects: [ | |||||
| { | |||||
| name: 'chromium', | |||||
| use: { ...devices['Desktop Chrome'] }, | |||||
| }, | |||||
| ], | |||||
| }) | |||||
| @@ -0,0 +1,77 @@ | |||||
| lockfileVersion: '9.0' | |||||
| settings: | |||||
| autoInstallPeers: true | |||||
| excludeLinksFromLockfile: false | |||||
| importers: | |||||
| .: | |||||
| devDependencies: | |||||
| '@playwright/test': | |||||
| specifier: 1.55.0 | |||||
| version: 1.55.0 | |||||
| '@types/node': | |||||
| specifier: 22.18.1 | |||||
| version: 22.18.1 | |||||
| typescript: | |||||
| specifier: 5.9.3 | |||||
| version: 5.9.3 | |||||
| packages: | |||||
| '@playwright/test@1.55.0': | |||||
| resolution: {integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==} | |||||
| engines: {node: '>=18'} | |||||
| hasBin: true | |||||
| '@types/node@22.18.1': | |||||
| resolution: {integrity: sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==} | |||||
| fsevents@2.3.2: | |||||
| resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} | |||||
| engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | |||||
| os: [darwin] | |||||
| playwright-core@1.55.0: | |||||
| resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} | |||||
| engines: {node: '>=18'} | |||||
| hasBin: true | |||||
| playwright@1.55.0: | |||||
| resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} | |||||
| engines: {node: '>=18'} | |||||
| hasBin: true | |||||
| typescript@5.9.3: | |||||
| resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} | |||||
| engines: {node: '>=14.17'} | |||||
| hasBin: true | |||||
| undici-types@6.21.0: | |||||
| resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} | |||||
| snapshots: | |||||
| '@playwright/test@1.55.0': | |||||
| dependencies: | |||||
| playwright: 1.55.0 | |||||
| '@types/node@22.18.1': | |||||
| dependencies: | |||||
| undici-types: 6.21.0 | |||||
| fsevents@2.3.2: | |||||
| optional: true | |||||
| playwright-core@1.55.0: {} | |||||
| playwright@1.55.0: | |||||
| dependencies: | |||||
| playwright-core: 1.55.0 | |||||
| optionalDependencies: | |||||
| fsevents: 2.3.2 | |||||
| typescript@5.9.3: {} | |||||
| undici-types@6.21.0: {} | |||||
| @@ -0,0 +1,39 @@ | |||||
| # Redis 与全局界面规范测试报告 | |||||
| - 测试日期:2026-08-09 | |||||
| - Web:`http://127.0.0.1:6180`(局域网入口同步检查) | |||||
| - API:Gateway `6100`、Auth `6101`、Tran `6102` | |||||
| - Redis:Docker `ut-redis`,宿主机 `127.0.0.1:6380` | |||||
| ## 1. 自动化结果 | |||||
| | 范围 | 命令/检查 | 结果 | | |||||
| |---|---|---| | |||||
| | API 全量构建 | Java 17 `mvn clean package` | 通过;Gateway 1、Auth 24、Tran 58,共 83 项测试,0 failure / 0 error | | |||||
| | Web 类型与构建 | `pnpm run build` | 通过;仅保留既有大 chunk 警告 | | |||||
| | 模型发布弹窗主题回归 | `node --experimental-strip-types src/features/editors/model/model-editor-theme.test.mjs` | 通过 | | |||||
| | 列表与 Dialog E2E | `pnpm exec playwright test users-department-filter.spec.ts` | 2/2 通过 | | |||||
| | E2E TypeScript | `pnpm run typecheck` | 通过 | | |||||
| | Redis 容器 | healthcheck、`PING`、maxmemory 配置 | healthy、PONG、256 MB / volatile-lru | | |||||
| | 运行联调 | Auth health、Redis health、Auth 直连 ping、Gateway 转发 ping | 全部 UP | | |||||
| ## 2. E2E 覆盖 | |||||
| 1. 用户管理部门树与人员列表保持独立分区。 | |||||
| 2. 用户搜索工具栏与人员表格位于同一个列表 Card。 | |||||
| 3. 审计搜索工具栏与表格位于同一个列表 Card。 | |||||
| 4. 桌面关键词输入框宽度不超过 390px。 | |||||
| 5. Element Plus Dialog 可拖拽、垂直居中,点击遮罩后保持打开。 | |||||
| 6. 表格边框、斑马线、中文分页、总数、页大小和跳转保持正常。 | |||||
| ## 3. 截图 | |||||
| - `artifacts/screenshots/chromium--users-department-filter.spec--用户列表全局中文并支持部门树选择与再次点击取消--users-dialog-global-behavior.png` | |||||
| - `artifacts/screenshots/chromium--users-department-filter.spec--用户列表全局中文并支持部门树选择与再次点击取消--users-department-selected.png` | |||||
| - `artifacts/screenshots/chromium--users-department-filter.spec--用户列表全局中文并支持部门树选择与再次点击取消--users-department-cleared.png` | |||||
| - `artifacts/screenshots/chromium--users-department-filter.spec--审计表格与分页遵循系统统一规范--audit-table-pagination.png` | |||||
| ## 4. 边界说明 | |||||
| - 本轮浏览器连接未提供已有登录会话,仓库也没有 E2E 密码文件,因此列表 UI 使用 Playwright 路由模拟真实 API envelope 进行可重复验证;未伪称完成真实账号登录回归。 | |||||
| - Redis 真实容器与 Auth 运行健康已联调;登录/刷新/失效矩阵由 Auth 单元测试覆盖。Redis 中不保存原始 token。 | |||||
| @@ -0,0 +1,112 @@ | |||||
| # 三维编辑器 E2E 测试报告 | |||||
| ## 当前状态 | |||||
| - 用例状态:已完成测试设计、选择器审查、TypeScript 静态检查和无凭据浏览器烟测。 | |||||
| - 服务状态:Gateway、Auth、Tran 与 Web 已在前台 Windows Terminal 中启动;三个 API 健康检查及 Web 本机/局域网访问均正常。 | |||||
| - 执行状态:**完整 Playwright 业务用例尚未运行**。当前仅缺本机 `E2E_ADMIN_USERNAME`、`E2E_ADMIN_PASSWORD`(或忽略提交的 `.env`)提供的可登录测试账号;不会猜测、重置或在报告中记录密码。 | |||||
| - 本报告不记录账号、密码、访问令牌、请求头或本机绝对资源路径。 | |||||
| ## 验收范围 | |||||
| | 模块 | 验收项 | 自动化方式 | | |||||
| | --- | --- | --- | | |||||
| | 模型编辑器 | 无 iframe、原生 Three.js canvas | DOM 合同断言 | | |||||
| | 模型资源 | 真实 `excavator-a.glb` 上传、进度、服务端保存 | UI + 上传凭证 + SHA-256/长度 + HEAD + Range 206/416 | | |||||
| | 模型编辑 | 节点选择、变换、软删除、刷新后恢复 | 场景树/属性面板 UI + 服务端工程 JSON | | |||||
| | 动画与蓝图 | 新增时间轴关键帧、新增蓝图节点 | 编辑器 DOM + 保存刷新 + 工程 JSON | | |||||
| | 模型发布 | 发布检查、发布状态、精确发布版本 | UI + API 状态 | | |||||
| | 场景编辑器 | 无 iframe、原生 Three.js canvas | DOM 合同断言 | | |||||
| | 真实车间 | 真实车间模板、车间部件软删除/恢复 | UI + 保存刷新 + 工程 JSON | | |||||
| | 场景编排 | 从已发布模型目录放置、变换、设备语义 | UI + 工程 JSON | | |||||
| | 版本依赖 | `SCENE_MODEL` 精确引用项目和发布版本 | API 合同断言 | | |||||
| | 场景发布 | 8 项发布校验与发布状态 | UI + API 状态 | | |||||
| | 清理 | 场景优先、模型随后删除 | `finally` API 清理 + JSON 附件 | | |||||
| ## 最终选择器基线 | |||||
| ### 模型编辑器 | |||||
| - 宿主:`.model-editor-host` | |||||
| - 原生画布:`.render-host canvas` | |||||
| - 上传文件框:`#model-file-input` | |||||
| - 场景树:`#scene-tree [data-object-id]` | |||||
| - 变换值:`[data-vector="position|rotation|scale"]` | |||||
| - 软删除/恢复:`[data-object-delete]`、`[data-action="toggle-deleted"]` | |||||
| - 时间轴:`[data-bottom-tab="timeline"]`、`[data-action="add-key"]` | |||||
| - 蓝图:`[data-bottom-tab="blueprint"]`、`.bp-toolbar [data-command="add"]` | |||||
| - 保存/发布:`[data-action="save"]`、`[data-action="publish"]`、`[data-action="publish-model-asset"]` | |||||
| ### 场景编辑器 | |||||
| - 根节点:`.scene-workspace` | |||||
| - 原生画布:`.viewport-host canvas.scene-editor-canvas` | |||||
| - 左侧页签:`.panel-tabs button` | |||||
| - 已发布模型卡:`.asset-card` | |||||
| - 真实车间模板:`.template-card` | |||||
| - 车间部件:`.tree-row.child` | |||||
| - 部件软删除/恢复:`.danger-zone button.danger`、`.restore-notice button` | |||||
| - 属性变换:`.property-section .vector-field input` | |||||
| - 持久化引用:`.reference-card` | |||||
| - 校验/发布:`.validation-grid`、`.publish-button` | |||||
| ## 自动化边界 | |||||
| Three.js gizmo 的像素坐标会随视口尺寸、相机、模型包围盒和显卡时序变化,因此不把鼠标拖拽作为稳定合同。用例仍通过编辑器自己的属性面板修改同一变换数据,并在保存后读取服务端工程 JSON 二次校验,覆盖真实业务结果。 | |||||
| 蓝图自由画布的连线拖拽同样不作为稳定入口;用例通过蓝图工具栏新增节点,保存并刷新后核对节点集合。时间轴使用可定位的 DOM 轨道设置播放头并新增关键帧。 | |||||
| ## 计划生成的关键截图 | |||||
| 1. `three-model-native-canvas` | |||||
| 2. `three-model-real-glb-upload-progress` | |||||
| 3. `three-model-soft-delete-persisted` | |||||
| 4. `three-model-transform-timeline-blueprint` | |||||
| 5. `three-model-saved-and-restored` | |||||
| 6. `three-model-published` | |||||
| 7. `three-scene-workshop-part-deleted-after-refresh` | |||||
| 8. `three-scene-published-model-reference` | |||||
| 9. `three-scene-saved-and-restored` | |||||
| 10. `three-scene-eight-checks-passed` | |||||
| 11. `three-scene-published` | |||||
| 截图写入 `artifacts/screenshots`,HTML/JSON 报告同时附带;截图阶段均在登录完成后,不包含登录密码或访问令牌。 | |||||
| ## 执行命令(服务版本确认后) | |||||
| ```powershell | |||||
| pnpm run typecheck | |||||
| pnpm test -- content-authoring.spec.ts | |||||
| ``` | |||||
| ## 执行结果 | |||||
| | 项目 | 结果 | | |||||
| | --- | --- | | |||||
| | TypeScript 静态检查 | 通过 | | |||||
| | 登录页浏览器烟测 | 通过(1440×900) | | |||||
| | Playwright E2E | 待执行 | | |||||
| | 模型工程清理 | 待执行 | | |||||
| | 场景工程清理 | 待执行 | | |||||
| 无凭据烟测截图:`artifacts/final-smoke/login-page.png`。该截图仅验证最终 Web 服务可加载、登录页布局完整、四角色入口与认证服务状态可见,不替代登录后的三维编辑器业务验收。 | |||||
| 运行时用例会额外生成 UTF-8 Markdown 执行摘要和 JSON 清理结果附件。工程删除为服务端软删除;SHA-256 内容寻址对象可能被其它版本复用,因此不会做破坏性的物理文件强删。 | |||||
| ## 2026-08-09 编辑器专项烟测 | |||||
| 本轮在不使用登录凭据、不修改业务数据的条件下,对 Vite 最终源码执行独立浏览器挂载: | |||||
| - 登录页:局域网访问时管理员角色默认选中,账号输入框显示管理员占位提示。 | |||||
| - 模型编辑器:分别以明亮/暗黑主题挂载原生 Shadow DOM 工作台;1366、1440、1920 三档宽度均无编辑器、顶部工具栏或按钮横向溢出,18 个可见工具按钮全部保留。 | |||||
| - 场景编辑器:在 `isSecureContext=false`、`crypto.randomUUID` 不存在的真实局域网 HTTP 环境中成功生成场景 UUID、挂载 Three.js canvas 和 4 个初始对象。 | |||||
| - 场景历史:修复响应式 Proxy 导致的 `structuredClone` 异常后,浏览器挂载期间无 `randomUUID`、`DataCloneError`、`TypeError` 或未处理异常。 | |||||
| 专项截图: | |||||
| 1. `artifacts/final-smoke/login-admin-default.png` | |||||
| 2. `artifacts/final-smoke/model-editor-light-1440.png` | |||||
| 3. `artifacts/final-smoke/model-editor-dark-1920.png` | |||||
| 4. `artifacts/final-smoke/scene-editor-lan-loaded.png` | |||||
| 上述专项烟测验证页面初始化、主题和布局,不替代需要管理员测试凭据的完整创建、服务端保存、刷新恢复与发布 E2E。 | |||||
| @@ -0,0 +1,29 @@ | |||||
| # 密码策略与重置 E2E 测试报告 | |||||
| ## 测试范围 | |||||
| - 用户管理支持“系统重置”和“手动重置”两种方式; | |||||
| - 系统重置成功后仅显示一次临时密码; | |||||
| - 手动重置实时展示 8–20 位、大写字母、小写字母、数字、特殊符号五项规则; | |||||
| - 个人中心修改密码复用相同规则; | |||||
| - 首次登录强制改密复用相同规则; | |||||
| - 用户管理原有部门筛选、分页与审计页面回归。 | |||||
| ## 环境与数据边界 | |||||
| - 浏览器:Playwright Chromium; | |||||
| - Web:本机 `http://127.0.0.1:6180`; | |||||
| - API:由 Playwright Page Route 提供状态化 Mock,不访问或修改真实 Auth、KingBase、Redis; | |||||
| - 测试密码只存在于进程内请求断言,不写入本报告、截图或应用日志。 | |||||
| ## 结果 | |||||
| - TypeScript E2E 类型检查:通过; | |||||
| - `platform-account.spec.ts` 与 `users-department-filter.spec.ts`:6/6 通过; | |||||
| - 关键截图:`artifacts/screenshots/*password-reset-manual-requirements.png`、`*forced-password-requirements.png`; | |||||
| - HTML:`artifacts/html-report/index.html`; | |||||
| - JSON:`artifacts/results.json`。 | |||||
| ## 说明 | |||||
| 本报告证明 Web 交互和请求契约,不等同真实 API 联调。服务端密码规则另由 `ut_auth` 单元测试及 Maven 构建验证。 | |||||
| @@ -0,0 +1,133 @@ | |||||
| # 教学实施模块测试报告 | |||||
| ## 1. 本轮结论与边界 | |||||
| 本轮对教学实施模块执行了浏览器级状态化 UI 契约回归,覆盖指导书、训练记录、教员预览、对抗监控、三通道运行工作台、正式考核保护和沉浸本地配置。 | |||||
| > **重要:本报告中的 16 项通过均基于 Playwright 状态化 Mock,不等于真实 API、KingBase、Redis、Nginx 或测试服务器联调通过。** Mock 会按照最终公开 API 校验方法、DTO、版本、角色和状态变化,但不会访问或修改真实 UAT 数据。本轮没有启停服务,也没有清理真实测试数据。 | |||||
| | 验证层级 | 本轮状态 | 说明 | | |||||
| | --- | --- | --- | | |||||
| | Playwright 状态化 UI 契约 | 通过 | 16 / 16,Chromium 单 worker | | |||||
| | E2E TypeScript | 通过 | `tsc --noEmit` | | |||||
| | 真实 API 只读联调 | 未执行 | `teaching-live.spec.ts` 独立执行,不能由 Mock 结果替代 | | |||||
| | Java 策略与数据库测试 | 不在本报告范围 | 由 API 工程测试负责 | | |||||
| | 部署环境烟测 | 不在本报告范围 | 由发布后的 IP + 端口环境验证 | | |||||
| ## 2. 本轮重点回归 | |||||
| ### 2.1 指导书与训练记录 | |||||
| - 从虚拟仿真任务模块进入“作业指导书”。 | |||||
| - 打开 2 章 3 步的正式发布指导书,依次阅读“断电隔离”“压力释放”“复装与复测”。 | |||||
| - 验证章节目录、正文、安全要求、验收标准、技术参数和阅读进度。 | |||||
| - 从全文阅读返回指导书列表,再返回训练任务页。 | |||||
| - 从任务页进入训练记录,验证搜索区与单一表格结构。 | |||||
| - 打开训练记录详情,验证成绩、评定结果和 `2 / 2` 步骤快照。 | |||||
| - 从详情返回记录列表,再进入“完整过程”。 | |||||
| - 完整过程展示真实运行工作台和服务端公开事件;教员身份为只读观察,不出现学员写操作。 | |||||
| - 从完整过程返回后仍保持 `?view=records`,再可回到训练任务页。 | |||||
| ### 2.2 教员预览 | |||||
| - 教员预览为完整工作台:步骤树、真实 Three.js 场景、教学指导、数字教员、规则检测点、操作坞和视角控制均存在。 | |||||
| - 不使用 iframe。 | |||||
| - 预览中的步骤试走和过程记录仅保存在浏览器内。 | |||||
| - 测试对比交互前后的请求,`POST / PUT / DELETE` 数量保持不变。 | |||||
| - 未接入的语音和规则执行明确标识,不伪造成功结果。 | |||||
| ### 2.3 对抗任务监控 | |||||
| - 从任务卡进入独立监控路由 `/teaching/confrontation/tasks/{assignmentId}/monitor`。 | |||||
| - 验证 RED、BLUE 两条运行可切换,当前运行、成员、进度、事件和三维场景随选择更新。 | |||||
| - 验证监控页加载真实 Three.js 画布和已发布训练资产。 | |||||
| - 验证故障注入采用最终 API 公私载荷契约:公开载荷只有故障引用和公开说明;故障真值仅进入 `privatePayload`。 | |||||
| - 创建干预后继续调用激活接口,并在监控页显示活动干预。 | |||||
| - 从监控页进入三维观察,教员只能查看运行状态,不出现岗位推进和团队提交按钮。 | |||||
| ### 2.4 三通道运行工作台与正式考核 | |||||
| - 虚拟仿真:步骤轨、三维场景、规则目标、数字教员、运行日志和操作坞。 | |||||
| - 实装实训:步骤轨、实装镜像工位、设备/视觉/人工事件通道、过程评定和事件时间线。 | |||||
| - 对抗训练:步骤与岗位、三维协同态势、团队成员、公开故障、教学干预和负责人提交边界。 | |||||
| - 三个通道均不使用 iframe,均加载发布场景资产。 | |||||
| - 正式考核只消费服务端 execution 投影;页面只显示当前步骤,后续内容不从完整定义泄露。 | |||||
| - 正式考核不显示操作帮助、过程记录、回放、演示、提示和数字教员。 | |||||
| ### 2.5 沉浸交互本地配置 | |||||
| - 修改“扳机键”映射并保存到 `unreal-tran:teaching-xr-preferences:v1`。 | |||||
| - 刷新页面后从 `localStorage` 恢复映射。 | |||||
| - 点击“恢复默认”后还原默认映射并删除本地配置键。 | |||||
| - 本地配置只保存呈现模式和按键映射,不保存训练进度、校准结果或设备位姿。 | |||||
| ## 3. 自动化执行结果 | |||||
| | 检查 | 命令 | 结果 | | |||||
| | --- | --- | --- | | |||||
| | TypeScript | `npm run typecheck` | 通过 | | |||||
| | 新增聚焦回归 | `npx playwright test tests/teaching-module.spec.ts --project=chromium --reporter=list -g "指导书和训练记录\|对抗任务监控\|三通道运行工作台\|本地按键配置"` | 4 / 4 通过,57.1 秒 | | |||||
| | 教学模块全量 + 教员预览 | `npx playwright test tests/teaching-module.spec.ts tests/teaching-preview-workbench.spec.ts --project=chromium --reporter=list` | 16 / 16 通过,2.7 分钟 | | |||||
| 全量 16 项包括: | |||||
| 1. 六入口、指导书全文阅读、记录详情与完整过程往返。 | |||||
| 2. 对抗独立监控、运行切换、三维观察和故障注入。 | |||||
| 3. 虚拟/实装/对抗三通道工作台及正式考核锁定。 | |||||
| 4. 沉浸本地配置保存、刷新恢复和恢复默认。 | |||||
| 5. 纯行政任务与数据中心只读边界。 | |||||
| 6. UNION 行政 + 学员 COMMON 领取、开始、推进、提交和评定闭环。 | |||||
| 7. 超时结算和权限边界。 | |||||
| 8. 教员人工结束运行并继续评定。 | |||||
| 9. 对抗开训前结束与开训后超时边界。 | |||||
| 10. 实装两步 `physical-events` 权威推进。 | |||||
| 11. ASSIGNED 预建运行接收。 | |||||
| 12. 对抗多岗位推进、负责人提交与公开干预。 | |||||
| 13. 正式考核 Unix 秒和辅助能力锁定。 | |||||
| 14. 发布资产加载与 WebXR 桌面会话起止。 | |||||
| 15. 数据中心安全明细和 Unix 秒筛选。 | |||||
| 16. 教员预览完整工作台与零写请求。 | |||||
| ## 4. 状态化 Mock 与最终 API 的一致性 | |||||
| - 学员进度只提交 `completedStepCode` 和动作证据;百分比及下一步骤由服务端响应派生。 | |||||
| - 实装步骤只通过 `/runs/{id}/physical-events` 推进,不绕行通用 progress。 | |||||
| - 对抗岗位校验 actionCode、岗位和负责人提交资格。 | |||||
| - 正式考核使用 `/runs/{id}/execution` 安全投影,不向学员下发完整定义。 | |||||
| - 教学干预允许管理身份提交服务端私密载荷;公开载荷若包含 `privateTruth`、`triggerConfig`、`solution`、`answer` 或 `truth` 会被 Mock 拒绝。 | |||||
| - 激活干预使用版本号,返回服务端权威 `ACTIVE` 状态。 | |||||
| - 预览不调用写接口;本地沉浸偏好不调用教学 API。 | |||||
| - Mock 返回的时间字段遵循 Unix 秒口径。 | |||||
| ## 5. 视觉证据 | |||||
| 成功截图位于 `ute2e/artifacts/screenshots`,本轮新增的关键标签如下: | |||||
| - `teaching-guide-full-reader` | |||||
| - `teaching-training-record-detail` | |||||
| - `teaching-training-record-full-process` | |||||
| - `teaching-module-guide-record-roundtrip` | |||||
| - `teaching-confrontation-monitor-switch` | |||||
| - `teaching-confrontation-observer-runtime` | |||||
| - `teaching-runtime-virtual-workbench` | |||||
| - `teaching-runtime-physical-workbench` | |||||
| - `teaching-runtime-confrontation-workbench` | |||||
| - `teaching-runtime-formal-exam-lock` | |||||
| - `teaching-immersive-local-config-saved` | |||||
| - `teaching-immersive-local-config-restored` | |||||
| - `teaching-teacher-preview-initial` | |||||
| - `teaching-teacher-preview-workbench` | |||||
| 抽检结果:指导书正文、记录详情、对抗监控、三通道工作台、正式考核保护、沉浸配置和教员预览均能形成可判读的中文视觉证据;模型画布可见,页面未出现 iframe 或空白工作区。 | |||||
| ## 6. 制品位置 | |||||
| - HTML 报告:`ute2e/artifacts/html-report/index.html` | |||||
| - JSON 结果:`ute2e/artifacts/results.json` | |||||
| - 成功截图:`ute2e/artifacts/screenshots` | |||||
| - 失败诊断:`ute2e/artifacts/test-results` | |||||
| - 状态化 Mock:`ute2e/tests/teaching-contract-mock.ts` | |||||
| - 教学模块用例:`ute2e/tests/teaching-module.spec.ts` | |||||
| - 教员预览用例:`ute2e/tests/teaching-preview-workbench.spec.ts` | |||||
| 真实环境联调必须单独执行并单独报告;不得把本报告的 Mock 通过结论写成“真实后端已联调通过”。 | |||||
| @@ -0,0 +1,69 @@ | |||||
| # 登录上下文与个人中心 UI 契约测试报告 | |||||
| ## 1. 测试结论 | |||||
| - 测试日期:2026-08-11 | |||||
| - 测试对象:`http://127.0.0.1:6180/login`、`/profile` | |||||
| - 测试方式:Playwright Chromium;模拟公开登录上下文及已登录个人资料接口,不使用真实账号 | |||||
| - 执行结果:11 项通过,0 项失败,0 项跳过 | |||||
| - TypeScript 检查:通过 | |||||
| - 数据影响:未提交登录请求,未写入数据库,无测试数据残留 | |||||
| 本轮验证登录页面、账号菜单和个人中心与 Auth API 的 UI 契约,不替代真实 Auth API、数据库和 Redis 的集成测试。测试复用了执行前已经运行的 6180 前端实例,没有启动或重启任何前后端服务。 | |||||
| ## 2. 契约数据 | |||||
| 路由 mock 仅返回以下公开信息: | |||||
| - 默认身份:管理员 `admin` | |||||
| - 四个稳定身份按产品顺序展示:`administrative`、`teacher`、`student`、`admin` | |||||
| - 身份名称、账号字段名称、组织选择模式与组织字段名称 | |||||
| - 启用部门树的 `id/code/name/parentId/children` | |||||
| mock 不包含用户、用户名、人员姓名、密码、令牌或权限数据,避免把匿名登录上下文变成账号枚举接口。 | |||||
| ## 3. 用例结果 | |||||
| | 序号 | 用例 | 结果 | 关键检查 | | |||||
| | --- | --- | --- | --- | | |||||
| | 1 | 默认管理员且四个身份展示各自约定字段 | 通过 | 展示顺序为行政、教员、学员、管理员,但默认仍选中 `admin`;各身份账号字段与组织字段正确 | | |||||
| | 2 | 行政身份按 SINGLE 模式只选择一个教学系 | 通过 | 只出现 `login-department`;只列顶层教学系;选择前禁止提交,选择后允许提交 | | |||||
| | 3 | 教员身份按 CASCADE 模式支持教学系本级和直属教研室 | 通过 | 出现 `login-organization` 和 `login-department`;下级先禁用;选择上级后同时显示“本级”和直属子级 | | |||||
| | 4 | 切换身份会清空账号密码和已选组织 | 通过 | 账号、密码、单级/级联组织路径均被清空,避免跨身份携带敏感输入 | | |||||
| | 5 | 登录页不枚举账号且不会预填密码 | 通过 | 账号为自由输入框,无 datalist/账号下拉;四种身份初始值均为空;eye 图标可切换密码显隐 | | |||||
| | 6 | 登录上下文失败时管理员账号密码入口仍可使用 | 通过 | 显示连接失败状态;管理员仍为默认身份;无组织依赖;填写账号密码后按钮可用 | | |||||
| | 7 | 375px 响应式检查 | 通过 | document、body 和登录卡片均未产生横向溢出 | | |||||
| | 8 | 1024px 响应式检查 | 通过 | document、body 和登录卡片均未产生横向溢出 | | |||||
| | 9 | 1440px 响应式检查 | 通过 | document、body 和登录卡片均未产生横向溢出 | | |||||
| | 10 | 精简页头和账号菜单 | 通过 | 页头不高于 62px,不再展示英文副标题;账号菜单仅有“个人中心、退出登录” | | |||||
| | 11 | 个人资料与修改密码 | 通过 | 展示姓名、账号、部门、授权模式和当前角色;三个密码框有 eye 图标;请求体使用旧密码和新密码并在成功后清空 | | |||||
| ## 4. 真实登录辅助函数调整 | |||||
| `tests/helpers.ts` 已按真实角色补充条件组织选择: | |||||
| - 行政:读取 `E2E_LOGIN_DEPARTMENT_NAME`,选择单级教学系。 | |||||
| - 教员:读取 `E2E_LOGIN_PARENT_DEPARTMENT_NAME` 和 `E2E_LOGIN_DEPARTMENT_NAME`,先选教学系,再选本级或直属教研室。 | |||||
| - 管理员、学员:不读取组织配置,也不提交组织选择。 | |||||
| - 若强制改密账号属于行政或教员但缺少相应组织环境变量,用例会给出明确原因并跳过,不会在登录按钮禁用时误判为业务失败。 | |||||
| - 管理员错误身份校验改用无需组织字段的学员身份,避免该用例被组织完整性校验提前阻断。 | |||||
| 本轮未提供真实行政/教员账号凭据,因此没有执行这两个身份的真实登录;辅助函数已通过 TypeScript 静态检查。 | |||||
| ## 5. 执行记录 | |||||
| ```text | |||||
| npm run typecheck | |||||
| 结果:通过 | |||||
| npm test -- auth-login-contract.spec.ts platform-account.spec.ts | |||||
| 结果:11 passed(登录 9 项与个人中心 2 项分别复跑均通过) | |||||
| ``` | |||||
| 自动化产物: | |||||
| - HTML 报告:`artifacts/html-report/index.html` | |||||
| - JSON 报告:`artifacts/results.json` | |||||
| - 成功截图目录:`artifacts/screenshots` | |||||
| 本轮生成 10 张成功截图,覆盖四身份字段、行政 SINGLE、教员 CASCADE、身份切换清理、上下文失败降级、375/1024/1440 三档宽度,以及精简页头/账号菜单和个人中心资料/改密。 | |||||
| @@ -0,0 +1,436 @@ | |||||
| import type { Locator, Page } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const loginContext = { | |||||
| defaultRoleCode: 'admin', | |||||
| roles: [ | |||||
| { | |||||
| code: 'administrative', | |||||
| label: '行政', | |||||
| accountLabel: '教学系领导账号', | |||||
| organizationMode: 'SINGLE', | |||||
| organizationLabels: ['教学系'], | |||||
| }, | |||||
| { | |||||
| code: 'teacher', | |||||
| label: '教员', | |||||
| accountLabel: '教员账号', | |||||
| organizationMode: 'CASCADE', | |||||
| organizationLabels: ['教学系', '教研室'], | |||||
| }, | |||||
| { | |||||
| code: 'student', | |||||
| label: '学员', | |||||
| accountLabel: '学号或账号', | |||||
| organizationMode: 'NONE', | |||||
| organizationLabels: [], | |||||
| }, | |||||
| { | |||||
| code: 'admin', | |||||
| label: '管理员', | |||||
| accountLabel: '管理员账号', | |||||
| organizationMode: 'NONE', | |||||
| organizationLabels: [], | |||||
| }, | |||||
| ], | |||||
| departments: [ | |||||
| { | |||||
| id: '100', | |||||
| code: 'EQUIPMENT', | |||||
| name: '工程装备系', | |||||
| parentId: null, | |||||
| children: [ | |||||
| { | |||||
| id: '110', | |||||
| code: 'MAINTENANCE', | |||||
| name: '维修教研室', | |||||
| parentId: '100', | |||||
| children: [ | |||||
| { | |||||
| id: '111', | |||||
| code: 'MAINTENANCE-GROUP-1', | |||||
| name: '维修一组', | |||||
| parentId: '110', | |||||
| children: [ | |||||
| { | |||||
| id: '112', | |||||
| code: 'HYDROPOWER-A', | |||||
| name: '水电组A', | |||||
| parentId: '111', | |||||
| children: [], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| { | |||||
| id: '120', | |||||
| code: 'SUPPORT', | |||||
| name: '智能保障教研室', | |||||
| parentId: '100', | |||||
| children: [], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| { | |||||
| id: '200', | |||||
| code: 'COMMAND', | |||||
| name: '指挥系', | |||||
| parentId: null, | |||||
| children: [ | |||||
| { | |||||
| id: '210', | |||||
| code: 'COMMAND-TEACHING', | |||||
| name: '指挥教研室', | |||||
| parentId: '200', | |||||
| children: [], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| ], | |||||
| } as const | |||||
| const loginIdentities = { | |||||
| administrative: [ | |||||
| { id: '301', displayName: '秦主任', departmentId: '100', departmentName: '工程装备系' }, | |||||
| { id: '302', displayName: '周主任', departmentId: '200', departmentName: '指挥系' }, | |||||
| ], | |||||
| teacher: [ | |||||
| { id: '401', displayName: '许教员', departmentId: '100', departmentName: '工程装备系' }, | |||||
| { id: '402', displayName: '杜晴', departmentId: '110', departmentName: '维修教研室' }, | |||||
| { id: '403', displayName: '马雅柔', departmentId: '112', departmentName: '水电组A' }, | |||||
| { id: '404', displayName: '韩教员', departmentId: '120', departmentName: '智能保障教研室' }, | |||||
| ], | |||||
| student: [ | |||||
| { id: '501', displayName: '学生甲', departmentId: '112', departmentName: '水电组A' }, | |||||
| ], | |||||
| } as const | |||||
| const descendantDepartmentIds: Record<string, ReadonlySet<string>> = { | |||||
| '100': new Set(['100', '110', '111', '112', '120']), | |||||
| '110': new Set(['110', '111', '112']), | |||||
| '120': new Set(['120']), | |||||
| '200': new Set(['200', '210']), | |||||
| '210': new Set(['210']), | |||||
| } | |||||
| const mockLoginContext = async (page: Page) => { | |||||
| await page.route('**/api/auth/v1/auth/login-context', async (route) => { | |||||
| await route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ | |||||
| code: 200, | |||||
| message: '成功', | |||||
| data: loginContext, | |||||
| timestamp: '2026-08-11T00:00:00+08:00', | |||||
| requestId: 'ute2e-login-context-success', | |||||
| }), | |||||
| }) | |||||
| }) | |||||
| await page.route('**/api/auth/v1/auth/login-identities**', async (route) => { | |||||
| const url = new URL(route.request().url()) | |||||
| const roleCode = url.searchParams.get('roleCode') | |||||
| const departmentId = url.searchParams.get('departmentId') | |||||
| const source = roleCode && roleCode in loginIdentities | |||||
| ? loginIdentities[roleCode as keyof typeof loginIdentities] | |||||
| : [] | |||||
| const allowedDepartmentIds = departmentId ? descendantDepartmentIds[departmentId] : null | |||||
| const identities = source.filter((identity) => !allowedDepartmentIds || allowedDepartmentIds.has(identity.departmentId)) | |||||
| await route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ | |||||
| code: 200, | |||||
| message: '成功', | |||||
| data: identities, | |||||
| timestamp: '2026-08-11T00:00:00+08:00', | |||||
| requestId: 'ute2e-login-identities-success', | |||||
| }), | |||||
| }) | |||||
| }) | |||||
| } | |||||
| const mockLoginContextFailure = async (page: Page) => { | |||||
| await page.route('**/api/auth/v1/auth/login-context', async (route) => { | |||||
| await route.fulfill({ | |||||
| status: 503, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ | |||||
| code: 50300, | |||||
| message: '认证配置暂不可用', | |||||
| data: null, | |||||
| timestamp: '2026-08-11T00:00:00+08:00', | |||||
| requestId: 'ute2e-login-context-failure', | |||||
| }), | |||||
| }) | |||||
| }) | |||||
| } | |||||
| const openLoginWithContext = async (page: Page) => { | |||||
| await mockLoginContext(page) | |||||
| await page.goto('/login') | |||||
| const contextState = page.getByText('身份认证服务正常', { exact: true }) | |||||
| await expect(contextState).toBeAttached() | |||||
| await expect(contextState).toHaveAttribute('aria-live', 'polite') | |||||
| await expect(contextState).toHaveCSS('position', 'absolute') | |||||
| await expect(contextState).toHaveCSS('clip-path', 'inset(50%)') | |||||
| } | |||||
| const adminUsernameInput = (page: Page) => page.locator('input[name="username"]') | |||||
| const passwordInput = (page: Page) => page.locator('input[name="password"]') | |||||
| const submitButton = (page: Page) => page.getByRole('button', { name: '登录系统' }) | |||||
| const identitySelect = (page: Page) => page.getByTestId('login-identity') | |||||
| const openElementSelect = async (page: Page, select: Locator) => { | |||||
| const combobox = select.getByRole('combobox') | |||||
| await select.locator('.el-select__wrapper').click() | |||||
| await expect(combobox).toHaveAttribute('aria-expanded', 'true') | |||||
| const listboxId = await combobox.getAttribute('aria-controls') | |||||
| expect(listboxId).toBeTruthy() | |||||
| const listbox = page.locator(`[id="${listboxId}"]`) | |||||
| await expect(listbox).toBeVisible() | |||||
| return listbox | |||||
| } | |||||
| const selectElementOption = async (page: Page, select: Locator, name: string | RegExp) => { | |||||
| const listbox = await openElementSelect(page, select) | |||||
| await listbox.getByRole('option', { name, exact: typeof name === 'string' }).click() | |||||
| } | |||||
| const expectElementSelectOptions = async (page: Page, select: Locator, expected: string[]) => { | |||||
| const listbox = await openElementSelect(page, select) | |||||
| const labels = (await listbox.getByRole('option').allTextContents()) | |||||
| .map((label) => label.replace(/\s+/g, ' ').trim()) | |||||
| expect(labels).toEqual(expected) | |||||
| await page.keyboard.press('Escape') | |||||
| } | |||||
| const expectElementSelectPlaceholder = async (select: Locator, placeholder: string) => { | |||||
| await expect(select.locator('.el-select__placeholder').first()).toContainText(placeholder) | |||||
| } | |||||
| test.describe('登录上下文 UI 契约', () => { | |||||
| test('默认管理员且四个身份展示各自约定字段', async ({ page }, testInfo) => { | |||||
| await openLoginWithContext(page) | |||||
| const roleButtons = page.locator('[data-role-code]') | |||||
| await expect(roleButtons).toHaveCount(4) | |||||
| expect(await roleButtons.evaluateAll((buttons) => buttons.map((button) => ({ | |||||
| code: button.getAttribute('data-role-code'), | |||||
| label: button.textContent?.trim(), | |||||
| })))).toEqual([ | |||||
| { code: 'administrative', label: '行政' }, | |||||
| { code: 'teacher', label: '教员' }, | |||||
| { code: 'student', label: '学员' }, | |||||
| { code: 'admin', label: '管理员' }, | |||||
| ]) | |||||
| await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true') | |||||
| await expect(adminUsernameInput(page)).toHaveAttribute('placeholder', '请输入管理员账号') | |||||
| await expect(identitySelect(page)).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-organization')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-department')).toHaveCount(0) | |||||
| await page.locator('[data-role-code="administrative"]').click() | |||||
| await expect(adminUsernameInput(page)).toHaveCount(0) | |||||
| await expect(identitySelect(page)).toBeVisible() | |||||
| await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号') | |||||
| await expect(identitySelect(page)).toBeDisabled() | |||||
| await expect(page.getByTestId('login-organization')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-department')).toBeVisible() | |||||
| await page.locator('[data-role-code="teacher"]').click() | |||||
| await expect(adminUsernameInput(page)).toHaveCount(0) | |||||
| await expect(identitySelect(page)).toBeVisible() | |||||
| await expectElementSelectPlaceholder(identitySelect(page), '请选择教员账号') | |||||
| await expect(identitySelect(page)).toBeDisabled() | |||||
| await expect(page.getByTestId('login-organization')).toBeVisible() | |||||
| await expect(page.getByTestId('login-department')).toBeVisible() | |||||
| await page.locator('[data-role-code="student"]').click() | |||||
| await expect(adminUsernameInput(page)).toHaveCount(0) | |||||
| await expect(identitySelect(page)).toBeVisible() | |||||
| await expectElementSelectPlaceholder(identitySelect(page), '请选择学员账号') | |||||
| await expect(identitySelect(page)).toBeEnabled() | |||||
| await expect(page.getByTestId('login-organization')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-department')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, 'four-role-fields') | |||||
| }) | |||||
| test('行政身份按 SINGLE 模式只选择一个教学系', async ({ page }, testInfo) => { | |||||
| await openLoginWithContext(page) | |||||
| await page.locator('[data-role-code="administrative"]').click() | |||||
| const department = page.getByTestId('login-department') | |||||
| await expect(department).toBeEnabled() | |||||
| await expectElementSelectPlaceholder(department, '请选择教学系') | |||||
| await expectElementSelectOptions(page, department, [ | |||||
| '工程装备系', | |||||
| '指挥系', | |||||
| ]) | |||||
| await expect(submitButton(page)).toBeDisabled() | |||||
| await selectElementOption(page, department, '工程装备系') | |||||
| await expect(identitySelect(page)).toBeEnabled() | |||||
| await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号') | |||||
| await selectElementOption(page, identitySelect(page), /秦主任.*工程装备系/) | |||||
| await passwordInput(page).fill('ContractOnly-NotSubmitted') | |||||
| await expect(submitButton(page)).toBeEnabled() | |||||
| await captureScreenshot(page, testInfo, 'administrative-single') | |||||
| }) | |||||
| test('教员身份按 CASCADE 模式支持教学系本级和直属教研室', async ({ page }, testInfo) => { | |||||
| await openLoginWithContext(page) | |||||
| await page.locator('[data-role-code="teacher"]').click() | |||||
| const organization = page.getByTestId('login-organization') | |||||
| const department = page.getByTestId('login-department') | |||||
| await expect(organization).toBeEnabled() | |||||
| await expect(department).toBeDisabled() | |||||
| await expect(identitySelect(page)).toBeDisabled() | |||||
| await expect(submitButton(page)).toBeDisabled() | |||||
| await selectElementOption(page, organization, '工程装备系') | |||||
| await expect(department).toBeEnabled() | |||||
| await expectElementSelectPlaceholder(department, '请选择教研室') | |||||
| await expectElementSelectOptions(page, department, [ | |||||
| '工程装备系(本级)', | |||||
| '维修教研室', | |||||
| '智能保障教研室', | |||||
| ]) | |||||
| await selectElementOption(page, department, '工程装备系(本级)') | |||||
| await expect(department).toContainText('工程装备系(本级)') | |||||
| await expect(identitySelect(page)).toBeEnabled() | |||||
| await selectElementOption(page, identitySelect(page), /许教员.*工程装备系/) | |||||
| await passwordInput(page).fill('ContractOnly-NotSubmitted') | |||||
| await expect(submitButton(page)).toBeEnabled() | |||||
| await selectElementOption(page, department, '维修教研室') | |||||
| await expect(department).toContainText('维修教研室') | |||||
| await expect(passwordInput(page)).toHaveValue('') | |||||
| await expect(identitySelect(page)).toBeEnabled() | |||||
| const identityListbox = await openElementSelect(page, identitySelect(page)) | |||||
| await expect(identityListbox.getByRole('option', { name: /马雅柔.*水电组A/ })).toBeVisible() | |||||
| await identityListbox.getByRole('option', { name: /马雅柔.*水电组A/ }).click() | |||||
| await passwordInput(page).fill('ContractOnly-NotSubmitted') | |||||
| await expect(submitButton(page)).toBeEnabled() | |||||
| await captureScreenshot(page, testInfo, 'teacher-cascade-current-and-child') | |||||
| }) | |||||
| test('切换身份会清空账号密码和已选组织', async ({ page }, testInfo) => { | |||||
| await openLoginWithContext(page) | |||||
| await page.locator('[data-role-code="teacher"]').click() | |||||
| await selectElementOption(page, page.getByTestId('login-organization'), '工程装备系') | |||||
| await selectElementOption(page, page.getByTestId('login-department'), '维修教研室') | |||||
| await selectElementOption(page, identitySelect(page), /马雅柔.*水电组A/) | |||||
| await passwordInput(page).fill('ContractOnly-NotSubmitted') | |||||
| await page.locator('[data-role-code="administrative"]').click() | |||||
| await expect(adminUsernameInput(page)).toHaveCount(0) | |||||
| await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号') | |||||
| await expect(passwordInput(page)).toHaveValue('') | |||||
| await expect(page.getByTestId('login-organization')).toHaveCount(0) | |||||
| await expectElementSelectPlaceholder(page.getByTestId('login-department'), '请选择教学系') | |||||
| await selectElementOption(page, page.getByTestId('login-department'), '指挥系') | |||||
| await selectElementOption(page, identitySelect(page), /周主任.*指挥系/) | |||||
| await passwordInput(page).fill('ContractOnly-NotSubmitted') | |||||
| await page.locator('[data-role-code="teacher"]').click() | |||||
| await expect(adminUsernameInput(page)).toHaveCount(0) | |||||
| await expectElementSelectPlaceholder(identitySelect(page), '请选择教员账号') | |||||
| await expect(passwordInput(page)).toHaveValue('') | |||||
| await expectElementSelectPlaceholder(page.getByTestId('login-organization'), '请选择教学系') | |||||
| await expectElementSelectPlaceholder(page.getByTestId('login-department'), '请选择教研室') | |||||
| await expect(page.getByTestId('login-department')).toBeDisabled() | |||||
| await captureScreenshot(page, testInfo, 'role-switch-clears-sensitive-fields') | |||||
| }) | |||||
| test('登录页只按当前身份和组织加载安全账号候选且不会预填密码', async ({ page }) => { | |||||
| await openLoginWithContext(page) | |||||
| await expect(adminUsernameInput(page)).toHaveValue('') | |||||
| await expect(passwordInput(page)).toHaveValue('') | |||||
| await expect(adminUsernameInput(page)).toHaveAttribute('autocomplete', 'username') | |||||
| await expect(passwordInput(page)).toHaveAttribute('autocomplete', 'current-password') | |||||
| await expect(page.locator('.login-password-toggle')).toBeVisible() | |||||
| await expect(page.locator('.login-password-toggle svg')).toHaveCount(1) | |||||
| await expect(page.locator('.login-password-toggle')).toHaveAttribute('aria-label', '显示密码') | |||||
| await page.locator('.login-password-toggle').click() | |||||
| await expect(passwordInput(page)).toHaveAttribute('type', 'text') | |||||
| await expect(page.locator('.login-password-toggle')).toHaveAttribute('aria-label', '隐藏密码') | |||||
| await page.locator('.login-password-toggle').click() | |||||
| await expect(passwordInput(page)).toHaveAttribute('type', 'password') | |||||
| expect(await adminUsernameInput(page).getAttribute('list')).toBeNull() | |||||
| await expect(page.locator('datalist')).toHaveCount(0) | |||||
| await expect(page.locator('select[name*="user" i], select[name*="account" i]')).toHaveCount(0) | |||||
| await page.locator('[data-role-code="administrative"]').click() | |||||
| await expect(adminUsernameInput(page)).toHaveCount(0) | |||||
| await expect(identitySelect(page)).toBeDisabled() | |||||
| await expect(passwordInput(page)).toHaveValue('') | |||||
| await page.locator('[data-role-code="teacher"]').click() | |||||
| await expect(adminUsernameInput(page)).toHaveCount(0) | |||||
| await expect(identitySelect(page)).toBeDisabled() | |||||
| await expect(passwordInput(page)).toHaveValue('') | |||||
| await page.locator('[data-role-code="student"]').click() | |||||
| await expect(adminUsernameInput(page)).toHaveCount(0) | |||||
| await expect(identitySelect(page)).toBeEnabled() | |||||
| const studentListbox = await openElementSelect(page, identitySelect(page)) | |||||
| await expect(studentListbox.getByRole('option', { name: /学生甲.*水电组A/ })).toBeVisible() | |||||
| await page.keyboard.press('Escape') | |||||
| await expect(passwordInput(page)).toHaveValue('') | |||||
| await page.locator('[data-role-code="admin"]').click() | |||||
| await expect(identitySelect(page)).toHaveCount(0) | |||||
| await expect(adminUsernameInput(page)).toHaveValue('') | |||||
| await expect(passwordInput(page)).toHaveValue('') | |||||
| }) | |||||
| test('登录上下文失败时管理员账号密码入口仍可使用', async ({ page }, testInfo) => { | |||||
| await mockLoginContextFailure(page) | |||||
| await page.goto('/login') | |||||
| const contextState = page.getByText('认证配置连接失败', { exact: true }) | |||||
| await expect(contextState).toBeAttached() | |||||
| await expect(contextState).toHaveAttribute('aria-live', 'polite') | |||||
| await expect(contextState).toBeVisible() | |||||
| await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true') | |||||
| await expect(page.getByTestId('login-organization')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-department')).toHaveCount(0) | |||||
| await adminUsernameInput(page).fill('admin.contract') | |||||
| await passwordInput(page).fill('ContractOnly-NotSubmitted') | |||||
| await expect(submitButton(page)).toBeEnabled() | |||||
| await captureScreenshot(page, testInfo, 'context-failure-admin-available') | |||||
| }) | |||||
| }) | |||||
| for (const viewport of [ | |||||
| { width: 375, height: 812 }, | |||||
| { width: 1024, height: 768 }, | |||||
| { width: 1440, height: 900 }, | |||||
| ]) { | |||||
| test(`登录页在 ${viewport.width}px 宽度无横向滚动`, async ({ page }, testInfo) => { | |||||
| await page.setViewportSize(viewport) | |||||
| await openLoginWithContext(page) | |||||
| const geometry = await page.evaluate(() => { | |||||
| const card = document.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect() | |||||
| return { | |||||
| documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, | |||||
| bodyOverflow: document.body.scrollWidth - document.body.clientWidth, | |||||
| cardLeft: card?.left ?? -1, | |||||
| cardRight: card?.right ?? Number.POSITIVE_INFINITY, | |||||
| viewportWidth: window.innerWidth, | |||||
| } | |||||
| }) | |||||
| expect(geometry.documentOverflow).toBeLessThanOrEqual(1) | |||||
| expect(geometry.bodyOverflow).toBeLessThanOrEqual(1) | |||||
| expect(geometry.cardLeft).toBeGreaterThanOrEqual(0) | |||||
| expect(geometry.cardRight).toBeLessThanOrEqual(geometry.viewportWidth + 1) | |||||
| await captureScreenshot(page, testInfo, `login-responsive-${viewport.width}`) | |||||
| }) | |||||
| } | |||||
| @@ -0,0 +1,86 @@ | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { | |||||
| adminCredentials, | |||||
| login, | |||||
| missingLoginOrganizationEnvironment, | |||||
| selectLoginOrganization, | |||||
| } from './helpers' | |||||
| test('管理员可以登录并安全退出', async ({ page }, testInfo) => { | |||||
| await page.goto('/login') | |||||
| const loginHeading = page.getByRole('heading', { name: '登录数字车间', includeHidden: true }) | |||||
| await expect(loginHeading).toBeAttached() | |||||
| await expect(page.locator('.login-card > header')).toHaveCSS('position', 'absolute') | |||||
| await expect(page.locator('.login-card > header')).toHaveCSS('clip-path', 'inset(50%)') | |||||
| await expect(page.locator('.login-role-options > button')).toHaveCount(4) | |||||
| await expect(page.getByRole('button', { name: /登录系统/ })).toBeDisabled() | |||||
| await captureScreenshot(page, testInfo, 'login-page') | |||||
| const credentials = adminCredentials() | |||||
| await page.locator('input[name="username"]').fill('__ute2e_missing_account__') | |||||
| await page.locator('input[name="password"]').fill('Uae2e-Invalid-Only') | |||||
| await page.getByRole('button', { name: /登录系统/ }).click() | |||||
| await expect(page.locator('.login-error')).toContainText('账号、密码或登录身份不匹配') | |||||
| await login(page, adminCredentials()) | |||||
| await expect(page).toHaveURL(/\/dashboard(?:\?|$)/) | |||||
| await captureScreenshot(page, testInfo, 'login-success') | |||||
| await page.locator('.user-entry').click() | |||||
| await page.getByText('退出登录', { exact: true }).click() | |||||
| await expect(page).toHaveURL(/\/login(?:\?|$)/) | |||||
| const returnedLoginHeading = page.getByRole('heading', { name: '登录数字车间', includeHidden: true }) | |||||
| await expect(returnedLoginHeading).toBeAttached() | |||||
| await expect(page.locator('.login-card > header')).toHaveCSS('position', 'absolute') | |||||
| await expect(page.locator('.login-card > header')).toHaveCSS('clip-path', 'inset(50%)') | |||||
| }) | |||||
| test('强制改密账号首次登录后必须设置新密码', async ({ page }, testInfo) => { | |||||
| const username = process.env.E2E_FORCE_CHANGE_USERNAME?.trim() | |||||
| const password = process.env.E2E_FORCE_CHANGE_PASSWORD?.trim() | |||||
| const newPassword = process.env.E2E_FORCE_CHANGE_NEW_PASSWORD?.trim() | |||||
| const roleCode = (process.env.E2E_FORCE_CHANGE_ROLE_CODE?.trim() || 'teacher') as 'admin' | 'administrative' | 'teacher' | 'student' | |||||
| const missingOrganizationEnvironment = missingLoginOrganizationEnvironment(roleCode) | |||||
| test.skip(!username || !password || !newPassword, '未配置独立强制改密账号,按约定跳过该条件用例。') | |||||
| test.skip(username === process.env.E2E_ADMIN_USERNAME, '强制改密用例禁止复用管理员账号。') | |||||
| test.skip( | |||||
| missingOrganizationEnvironment.length > 0, | |||||
| `当前登录身份缺少组织配置:${missingOrganizationEnvironment.join('、')}。`, | |||||
| ) | |||||
| await page.goto('/login') | |||||
| await page.locator(`[data-role-code="${roleCode}"]`).click() | |||||
| await selectLoginOrganization(page, roleCode) | |||||
| await page.locator('input[name="username"]').fill(username!) | |||||
| await page.locator('input[name="password"]').fill(password!) | |||||
| await page.getByRole('button', { name: /登录系统/ }).click() | |||||
| const surface = page.locator('.el-dialog:visible, .force-password-page, main').filter({ hasText: /修改密码|首次登录/ }).last() | |||||
| await expect(surface).toBeVisible() | |||||
| const currentPasswordInput = surface | |||||
| .locator('.el-form-item') | |||||
| .filter({ hasText: /当前密码/ }) | |||||
| .locator('input') | |||||
| .first() | |||||
| const newPasswordInput = surface | |||||
| .locator('.el-form-item') | |||||
| .filter({ hasText: /新密码/ }) | |||||
| .locator('input') | |||||
| .first() | |||||
| const confirmationInput = surface | |||||
| .locator('.el-form-item') | |||||
| .filter({ hasText: /确认密码|再次输入/ }) | |||||
| .locator('input') | |||||
| .first() | |||||
| await currentPasswordInput.fill(password!) | |||||
| await newPasswordInput.fill(newPassword!) | |||||
| await confirmationInput.fill(newPassword!) | |||||
| await surface.getByRole('button', { name: /确认修改|修改密码|保存/ }).last().click() | |||||
| await expect(page).toHaveURL(/\/login(?:\?|$)/) | |||||
| await login(page, { username: username!, password: newPassword!, roleCode }) | |||||
| await expect(page).toHaveURL(/\/dashboard(?:\?|$)/) | |||||
| await captureScreenshot(page, testInfo, 'forced-password-change-success') | |||||
| }) | |||||
| @@ -0,0 +1,644 @@ | |||||
| import { createHash } from 'node:crypto' | |||||
| import fs from 'node:fs' | |||||
| import { fileURLToPath } from 'node:url' | |||||
| import type { Locator, Page, Response, TestInfo } from '@playwright/test' | |||||
| import { | |||||
| closeContentApiSession, | |||||
| type CleanupRecord, | |||||
| type ContentApiSession, | |||||
| type ContentDetailRecord, | |||||
| openContentApiSession, | |||||
| readContentDetail, | |||||
| removeContentProject, | |||||
| } from './content-editor-api' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { chooseSelectOption, confirmMessageBox, fillFormItem, loginAsAdmin, visibleDialog } from './helpers' | |||||
| const runToken = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` | |||||
| const upperToken = runToken.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-16) | |||||
| const defaultExcavatorPath = fileURLToPath(new URL('../../unreal_tran_web/public/models/excavator-a.glb', import.meta.url)) | |||||
| const excavatorPath = process.env.E2E_EXCAVATOR_GLB?.trim() || defaultExcavatorPath | |||||
| const data = { | |||||
| prefix: `UTE2E-3D-${upperToken}`, | |||||
| model: { | |||||
| path: '/content/models', | |||||
| routeSegment: 'models', | |||||
| singular: '模型', | |||||
| code: `E2E_MODEL_${upperToken}`.slice(0, 64), | |||||
| name: `UTE2E 挖掘机模型 ${upperToken}`, | |||||
| category: '整机装备', | |||||
| description: `Playwright 三维模型编辑器验收数据 ${upperToken}`, | |||||
| }, | |||||
| scene: { | |||||
| path: '/content/scenes', | |||||
| routeSegment: 'scenes', | |||||
| singular: '场景', | |||||
| code: `E2E_SCENE_${upperToken}`.slice(0, 64), | |||||
| name: `UTE2E 数字车间 ${upperToken}`, | |||||
| category: '维修车间', | |||||
| description: `Playwright 数字车间场景编辑器验收数据 ${upperToken}`, | |||||
| }, | |||||
| sceneDeviceId: `EQ-E2E-${upperToken}`.slice(0, 64), | |||||
| modelPositionX: 1.25, | |||||
| scenePositionX: 2.5, | |||||
| } as const | |||||
| type ProjectSeed = typeof data.model | typeof data.scene | |||||
| interface ApiEnvelope<T> { | |||||
| data?: T | |||||
| } | |||||
| interface UploadedAssetCommand { | |||||
| code: string | |||||
| name: string | |||||
| type: string | |||||
| storageUri: string | |||||
| mimeType: string | |||||
| sizeBytes: number | |||||
| sha256: string | |||||
| metadata: Record<string, unknown> | |||||
| status: string | |||||
| sortOrder: number | |||||
| uploadTicket?: string | |||||
| } | |||||
| interface ModelDocument { | |||||
| schema?: string | |||||
| version?: number | |||||
| modelResource?: { assetCode?: string; storageUri?: string; sha256?: string } | |||||
| model?: { objects?: Record<string, { name?: string; position?: number[]; deleted?: boolean }> } | |||||
| timeline?: { tracks?: Array<{ keyframes?: unknown[] }> } | |||||
| blueprint?: { nodes?: unknown[]; edges?: unknown[] } | |||||
| } | |||||
| interface SceneObjectDocument { | |||||
| id?: string | |||||
| name?: string | |||||
| type?: string | |||||
| deleted?: boolean | |||||
| visible?: boolean | |||||
| position?: number[] | |||||
| targetProjectId?: string | |||||
| targetVersionId?: string | |||||
| assetCode?: string | |||||
| assetUrl?: string | |||||
| semantic?: { deviceId?: string } | |||||
| } | |||||
| interface SceneDocument { | |||||
| schema?: string | |||||
| version?: string | |||||
| objects?: SceneObjectDocument[] | |||||
| } | |||||
| const responsePath = (response: Response) => new URL(response.url()).pathname | |||||
| const isProjectMutation = (response: Response, method: 'PUT' | 'POST', projectId: string, suffix = '') => ( | |||||
| response.request().method() === method | |||||
| && responsePath(response) === `/api/tran/v1/content/projects/${projectId}${suffix}` | |||||
| ) | |||||
| const waitForProjectUpdate = (page: Page, projectId: string) => page.waitForResponse( | |||||
| (response) => isProjectMutation(response, 'PUT', projectId), | |||||
| { timeout: 60_000 }, | |||||
| ) | |||||
| async function expectSuccessfulResponse(responsePromise: Promise<Response>, operation: string): Promise<Response> { | |||||
| const response = await responsePromise | |||||
| expect(response.ok(), `${operation}应返回成功状态,实际 HTTP ${response.status()}`).toBeTruthy() | |||||
| return response | |||||
| } | |||||
| async function waitForContentList(page: Page): Promise<void> { | |||||
| await expect(page.locator('.content-table-panel')).toBeVisible() | |||||
| await expect(page.locator('.content-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 20_000 }) | |||||
| await expect(page.locator('.content-projects-page > .el-alert--error:visible')).toHaveCount(0) | |||||
| } | |||||
| async function createContentProject(page: Page, seed: ProjectSeed): Promise<string> { | |||||
| await page.goto(seed.path) | |||||
| await waitForContentList(page) | |||||
| await page.getByRole('button', { name: `新建${seed.singular}`, exact: true }).click() | |||||
| const dialog = visibleDialog(page) | |||||
| await expect(dialog).toContainText(`新建${seed.singular}`) | |||||
| await fillFormItem(dialog, '项目编码', seed.code) | |||||
| await fillFormItem(dialog, '项目名称', seed.name) | |||||
| await chooseSelectOption(page, dialog, '业务分类', seed.category) | |||||
| await fillFormItem(dialog, '项目说明', seed.description) | |||||
| const createdResponsePromise = page.waitForResponse((response) => ( | |||||
| response.request().method() === 'POST' | |||||
| && responsePath(response) === '/api/tran/v1/content/projects' | |||||
| ), { timeout: 30_000 }) | |||||
| await dialog.getByRole('button', { name: '创建并编辑', exact: true }).click() | |||||
| const createdResponse = await expectSuccessfulResponse(createdResponsePromise, `新建${seed.singular}`) | |||||
| const body = await createdResponse.json() as ApiEnvelope<ContentDetailRecord> | |||||
| const projectId = String(body.data?.project.id ?? '') | |||||
| expect(projectId, `新建${seed.singular}后应返回工程 ID`).not.toBe('') | |||||
| await expect(page).toHaveURL(new RegExp(`/content/${seed.routeSegment}/${projectId}/edit(?:\\?|$)`), { timeout: 20_000 }) | |||||
| return projectId | |||||
| } | |||||
| async function waitForModelEditor(page: Page): Promise<Locator> { | |||||
| const editor = page.locator('.model-editor-view') | |||||
| await expect(editor).toBeVisible({ timeout: 30_000 }) | |||||
| await expect(editor.locator('.editor-shell')).toBeVisible({ timeout: 30_000 }) | |||||
| await expect(editor.locator('.render-host canvas')).toBeVisible({ timeout: 30_000 }) | |||||
| await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 60_000 }) | |||||
| await expect(page.locator('iframe')).toHaveCount(0) | |||||
| return editor | |||||
| } | |||||
| async function waitForSceneEditor(page: Page): Promise<Locator> { | |||||
| const editor = page.locator('.scene-workspace') | |||||
| await expect(editor).toBeVisible({ timeout: 30_000 }) | |||||
| await expect(editor.locator('.viewport-host canvas.scene-editor-canvas')).toBeVisible({ timeout: 60_000 }) | |||||
| await expect(editor.locator('.loading-overlay')).toHaveCount(0, { timeout: 60_000 }) | |||||
| await expect(page.locator('iframe')).toHaveCount(0) | |||||
| return editor | |||||
| } | |||||
| const modelTreeRow = (editor: Locator, name: string) => editor | |||||
| .locator('#scene-tree .tree-row') | |||||
| .filter({ hasText: name }) | |||||
| .first() | |||||
| const sceneTreeRow = (editor: Locator, name: string) => editor | |||||
| .locator('.scene-tree .tree-row') | |||||
| .filter({ hasText: name }) | |||||
| .first() | |||||
| async function saveModel(page: Page, editor: Locator, projectId: string): Promise<void> { | |||||
| const responsePromise = waitForProjectUpdate(page, projectId) | |||||
| await editor.locator('[data-action="save"]').click() | |||||
| await expectSuccessfulResponse(responsePromise, '保存模型工程') | |||||
| await expect(editor.locator('.toast.success').filter({ hasText: '工程已保存到服务端' }).last()).toBeVisible({ timeout: 20_000 }) | |||||
| } | |||||
| async function saveScene(page: Page, editor: Locator, projectId: string): Promise<void> { | |||||
| const responsePromise = waitForProjectUpdate(page, projectId) | |||||
| await editor.getByTitle('保存工程 Ctrl+S').click() | |||||
| await expectSuccessfulResponse(responsePromise, '保存场景工程') | |||||
| await expect(page.locator('.el-message--success').filter({ hasText: /保存成功|工程已保存/ }).last()).toBeVisible({ timeout: 20_000 }) | |||||
| } | |||||
| async function uploadExcavator( | |||||
| page: Page, | |||||
| editor: Locator, | |||||
| projectId: string, | |||||
| expectedHash: string, | |||||
| expectedSize: number, | |||||
| testInfo: TestInfo, | |||||
| ): Promise<UploadedAssetCommand> { | |||||
| const uploadPattern = `**/api/tran/v1/content/projects/${projectId}/assets/upload` | |||||
| let releaseResponse: () => void = () => undefined | |||||
| let notifyStored: (() => void) | null = null | |||||
| let notifyFailed: ((error: Error) => void) | null = null | |||||
| const responseHold = new Promise<void>((resolve) => { releaseResponse = resolve }) | |||||
| const serverStored = new Promise<void>((resolve, reject) => { | |||||
| notifyStored = resolve | |||||
| notifyFailed = reject | |||||
| }) | |||||
| await page.route(uploadPattern, async (route) => { | |||||
| try { | |||||
| const response = await route.fetch() | |||||
| if (!response.ok()) throw new Error(`资源上传接口返回 HTTP ${response.status()}`) | |||||
| notifyStored?.() | |||||
| await responseHold | |||||
| await route.fulfill({ response }) | |||||
| } catch (error) { | |||||
| notifyFailed?.(error instanceof Error ? error : new Error(String(error))) | |||||
| await route.abort('failed').catch(() => undefined) | |||||
| } | |||||
| }) | |||||
| const uploadResponsePromise = page.waitForResponse( | |||||
| (response) => isProjectMutation(response, 'POST', projectId, '/assets/upload'), | |||||
| { timeout: 60_000 }, | |||||
| ) | |||||
| const automaticSavePromise = waitForProjectUpdate(page, projectId) | |||||
| try { | |||||
| await editor.locator('#model-file-input').setInputFiles(excavatorPath) | |||||
| await serverStored | |||||
| await expect(editor.locator('#loading-progress')).toContainText( | |||||
| /正在(?:保存装备模型|上传).*(?:MB|%)/, | |||||
| { timeout: 20_000 }, | |||||
| ) | |||||
| await captureScreenshot(page, testInfo, 'three-model-real-glb-upload-progress') | |||||
| releaseResponse() | |||||
| const uploadResponse = await expectSuccessfulResponse(uploadResponsePromise, '上传真实 excavator-a.glb') | |||||
| const body = await uploadResponse.json() as ApiEnvelope<UploadedAssetCommand> | |||||
| const asset = body.data | |||||
| expect(asset, '上传接口应返回可直接用于资产命令的元数据').toBeTruthy() | |||||
| expect(asset!.type).toBe('MODEL_FILE') | |||||
| expect(asset!.status).toBe('READY') | |||||
| expect(asset!.sizeBytes).toBe(expectedSize) | |||||
| expect(asset!.sha256).toBe(expectedHash) | |||||
| expect(asset!.storageUri).toBe(`content://sha256/${expectedHash}`) | |||||
| expect(asset!.uploadTicket, '新上传资源应返回与工程绑定的短期凭证').toMatch(/^v1\./) | |||||
| await expectSuccessfulResponse(automaticSavePromise, '上传后自动保存模型工程') | |||||
| await expect(editor.locator('.toast.success').filter({ hasText: /导入成功,刷新后可继续编辑/ }).last()).toBeVisible({ timeout: 60_000 }) | |||||
| await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 60_000 }) | |||||
| return asset! | |||||
| } finally { | |||||
| releaseResponse() | |||||
| await page.unroute(uploadPattern) | |||||
| } | |||||
| } | |||||
| function sumTimelineKeys(document: ModelDocument): number { | |||||
| return document.timeline?.tracks?.reduce((sum, track) => sum + (track.keyframes?.length ?? 0), 0) ?? 0 | |||||
| } | |||||
| async function assertStoredAsset( | |||||
| request: Parameters<typeof readContentDetail>[0], | |||||
| session: ContentApiSession, | |||||
| projectId: string, | |||||
| detail: ContentDetailRecord, | |||||
| uploaded: UploadedAssetCommand, | |||||
| ): Promise<void> { | |||||
| const stored = detail.currentVersion.assets.find((asset) => asset.code === uploaded.code) | |||||
| expect(stored, '工程保存后应产生带数据库 ID 的 READY 资产').toBeTruthy() | |||||
| expect(stored!.status).toBe('READY') | |||||
| expect(stored!.storageUri).toBe(uploaded.storageUri) | |||||
| expect(stored!.sha256).toBe(uploaded.sha256) | |||||
| expect(stored!.sizeBytes).toBe(uploaded.sizeBytes) | |||||
| const head = await request.head( | |||||
| `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`, | |||||
| { headers: session.headers }, | |||||
| ) | |||||
| expect(head.status()).toBe(200) | |||||
| expect(head.headers()['accept-ranges']).toBe('bytes') | |||||
| expect(head.headers()['content-length']).toBe(String(uploaded.sizeBytes)) | |||||
| expect(head.headers().etag).toBe(`"${uploaded.sha256}"`) | |||||
| const partial = await request.get( | |||||
| `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`, | |||||
| { headers: { ...session.headers, Range: 'bytes=0-63' } }, | |||||
| ) | |||||
| expect(partial.status()).toBe(206) | |||||
| expect(partial.headers()['content-range']).toBe(`bytes 0-63/${uploaded.sizeBytes}`) | |||||
| expect(partial.headers()['content-length']).toBe('64') | |||||
| expect((await partial.body()).byteLength).toBe(64) | |||||
| const invalidRange = await request.get( | |||||
| `/api/tran/v1/content/projects/${projectId}/assets/${stored!.id}/content`, | |||||
| { headers: { ...session.headers, Range: `bytes=${uploaded.sizeBytes}-` } }, | |||||
| ) | |||||
| expect(invalidRange.status()).toBe(416) | |||||
| expect(invalidRange.headers()['content-range']).toBe(`bytes */${uploaded.sizeBytes}`) | |||||
| } | |||||
| function markdownReport( | |||||
| completed: string[], | |||||
| cleanup: CleanupRecord[], | |||||
| projectIds: { model: string | null; scene: string | null }, | |||||
| passed: boolean, | |||||
| ): string { | |||||
| const cleanupRows = cleanup.length | |||||
| ? cleanup.map((item) => `| ${item.kind} | ${item.code} | ${item.id ?? '未创建'} | ${item.outcome} |`).join('\n') | |||||
| : '| — | — | — | 未执行 |' | |||||
| return `# 三维编辑器 E2E 执行摘要 | |||||
| - 执行状态:${passed ? '业务断言通过' : '业务断言未完成'} | |||||
| - 模型工程 ID:${projectIds.model ?? '未创建'} | |||||
| - 场景工程 ID:${projectIds.scene ?? '未创建'} | |||||
| - 测试模型:excavator-a.glb(报告不记录本机绝对路径) | |||||
| - 凭据与访问令牌:未写入报告、截图或附件 | |||||
| ## 已完成检查 | |||||
| ${completed.length ? completed.map((item) => `- ${item}`).join('\n') : '- 尚无完整步骤'} | |||||
| ## 自动化边界 | |||||
| - Three.js 视口原生 canvas 与无 iframe 由 DOM 直接断言。 | |||||
| - 视口 gizmo 的像素拖拽受相机、模型包围盒与显卡时序影响,不作为稳定 E2E 接口;变换改由同一编辑器属性面板操作,并在保存后通过服务端工程 JSON 二次核对。 | |||||
| - 蓝图连线的自由画布拖拽不作为本用例的稳定入口;本用例通过蓝图工具栏新增节点,并核对节点集合刷新后仍存在。时间轴通过 DOM 轨道定位并新增关键帧。 | |||||
| - 文件上传通过真实 GLB、上传进度 UI、SHA-256/长度、READY 状态、服务端 HEAD 元数据和刷新恢复联合验收。 | |||||
| ## 测试数据清理 | |||||
| | 类型 | 编码 | ID | 结果 | | |||||
| | --- | --- | --- | --- | | |||||
| ${cleanupRows} | |||||
| > 服务端工程采用软删除;内容寻址对象可能被其它版本复用,因此清理工程不会强制删除共享物理文件。 | |||||
| ` | |||||
| } | |||||
| test('装备模型与数字车间编辑器可原生编辑、保存、发布并恢复', async ({ page, request }, testInfo) => { | |||||
| test.setTimeout(300_000) | |||||
| let apiSession: ContentApiSession | null = null | |||||
| let modelProjectId: string | null = null | |||||
| let sceneProjectId: string | null = null | |||||
| let testFailure: unknown | |||||
| const completed: string[] = [] | |||||
| const cleanup: CleanupRecord[] = [] | |||||
| try { | |||||
| expect(fs.existsSync(excavatorPath), '真实测试模型 excavator-a.glb 必须存在').toBeTruthy() | |||||
| const modelSize = fs.statSync(excavatorPath).size | |||||
| const modelHash = createHash('sha256').update(fs.readFileSync(excavatorPath)).digest('hex') | |||||
| expect(modelSize).toBeGreaterThan(0) | |||||
| apiSession = await openContentApiSession(request) | |||||
| await loginAsAdmin(page) | |||||
| await test.step('新建模型工程并确认原生 Three.js 画布', async () => { | |||||
| modelProjectId = await createContentProject(page, data.model) | |||||
| const editor = await waitForModelEditor(page) | |||||
| await expect(editor.locator('.brand-copy')).toContainText('装备模型编辑工具') | |||||
| completed.push('模型编辑器无 iframe,开放 Shadow DOM 内原生 canvas 正常显示') | |||||
| await captureScreenshot(page, testInfo, 'three-model-native-canvas') | |||||
| }) | |||||
| let uploadedAsset: UploadedAssetCommand | |||||
| await test.step('上传真实 excavator-a.glb,显示进度并自动保存到服务端', async () => { | |||||
| const editor = await waitForModelEditor(page) | |||||
| uploadedAsset = await uploadExcavator(page, editor, modelProjectId!, modelHash, modelSize, testInfo) | |||||
| const detail = await readContentDetail(request, apiSession!, modelProjectId!) | |||||
| await assertStoredAsset(request, apiSession!, modelProjectId!, detail, uploadedAsset) | |||||
| completed.push('真实 GLB 上传进度、内容寻址 SHA-256、READY 资产及服务端文件 HEAD 元数据通过') | |||||
| }) | |||||
| let modelPartName = '' | |||||
| let modelPartKey = '' | |||||
| await test.step('模型部件软删除保存,刷新后恢复', async () => { | |||||
| let editor = await waitForModelEditor(page) | |||||
| await editor.locator('[data-selection-scope="part"]').click() | |||||
| const candidate = editor.locator('#scene-tree .tree-row:has(.tree-delete)').first() | |||||
| await expect(candidate, '真实 GLB 应至少包含一个可安全软删除的普通节点').toBeVisible({ timeout: 30_000 }) | |||||
| modelPartName = (await candidate.locator('.tree-label').textContent())?.trim() ?? '' | |||||
| expect(modelPartName).not.toBe('') | |||||
| await candidate.locator('.tree-label').click() | |||||
| modelPartKey = await editor.locator('#inspector .property-section').first().locator('.property-row').nth(1).locator('input').inputValue() | |||||
| expect(modelPartKey).not.toBe('') | |||||
| await editor.locator('[data-object-delete]').click() | |||||
| await expect(editor.locator('#inspector')).toContainText('已软删除') | |||||
| await saveModel(page, editor, modelProjectId!) | |||||
| await page.reload() | |||||
| editor = await waitForModelEditor(page) | |||||
| await editor.locator('[data-action="toggle-deleted"]').click() | |||||
| const deletedRow = modelTreeRow(editor, modelPartName) | |||||
| await expect(deletedRow).toHaveClass(/is-deleted/) | |||||
| await deletedRow.locator('.tree-label').click() | |||||
| await expect(editor.locator('[data-object-delete]')).toContainText('恢复部件') | |||||
| await captureScreenshot(page, testInfo, 'three-model-soft-delete-persisted') | |||||
| await editor.locator('[data-object-delete]').click() | |||||
| await expect(modelTreeRow(editor, modelPartName)).not.toHaveClass(/is-deleted/) | |||||
| completed.push('模型部件软删除写入服务端,刷新后仍为删除态,并可从 UI 恢复') | |||||
| }) | |||||
| let expectedTimelineKeys = 0 | |||||
| let expectedBlueprintNodes = 0 | |||||
| await test.step('编辑模型变换、时间轴与蓝图,保存并刷新恢复', async () => { | |||||
| let editor = await waitForModelEditor(page) | |||||
| const selectedRow = modelTreeRow(editor, modelPartName) | |||||
| await selectedRow.locator('.tree-label').click() | |||||
| const positionX = editor.locator('[data-vector="position"][data-axis="x"]') | |||||
| await positionX.fill(String(data.modelPositionX)) | |||||
| await positionX.press('Tab') | |||||
| await editor.locator('[data-bottom-tab="timeline"]').click() | |||||
| const lane = editor.locator('.timeline-lane').first() | |||||
| await expect(lane).toBeVisible() | |||||
| const laneBox = await lane.boundingBox() | |||||
| expect(laneBox, '时间轴轨道应有可交互尺寸').toBeTruthy() | |||||
| await lane.click({ position: { x: Math.max(20, laneBox!.width / 2), y: Math.max(2, laneBox!.height / 2) } }) | |||||
| const keys = editor.locator('.timeline-keyframe') | |||||
| const keyCountBefore = await keys.count() | |||||
| await editor.locator('[data-action="add-key"]').click() | |||||
| await expect(keys).toHaveCount(keyCountBefore + 1) | |||||
| expectedTimelineKeys = keyCountBefore + 1 | |||||
| await editor.locator('[data-bottom-tab="blueprint"]').click() | |||||
| const blueprintNodes = editor.locator('.bp-node') | |||||
| const nodeCountBefore = await blueprintNodes.count() | |||||
| await editor.locator('.bp-toolbar [data-command="add"]').click() | |||||
| await expect(blueprintNodes).toHaveCount(nodeCountBefore + 1) | |||||
| expectedBlueprintNodes = nodeCountBefore + 1 | |||||
| await captureScreenshot(page, testInfo, 'three-model-transform-timeline-blueprint') | |||||
| await saveModel(page, editor, modelProjectId!) | |||||
| await page.reload() | |||||
| editor = await waitForModelEditor(page) | |||||
| await modelTreeRow(editor, modelPartName).locator('.tree-label').click() | |||||
| await expect(editor.locator('[data-vector="position"][data-axis="x"]')).toHaveValue(data.modelPositionX.toFixed(3)) | |||||
| await editor.locator('[data-bottom-tab="timeline"]').click() | |||||
| await expect(editor.locator('.timeline-keyframe')).toHaveCount(expectedTimelineKeys) | |||||
| await editor.locator('[data-bottom-tab="blueprint"]').click() | |||||
| await expect(editor.locator('.bp-node')).toHaveCount(expectedBlueprintNodes) | |||||
| const detail = await readContentDetail(request, apiSession!, modelProjectId!) | |||||
| const document = detail.currentVersion.content as ModelDocument | |||||
| expect(document.schema).toBe('digital-twin-editor-project') | |||||
| expect(document.version).toBe(4) | |||||
| expect(document.modelResource?.assetCode).toBe(uploadedAsset.code) | |||||
| expect(document.modelResource?.storageUri).toBe(uploadedAsset.storageUri) | |||||
| const storedPart = document.model?.objects?.[modelPartKey] | |||||
| expect(storedPart, '服务端工程 JSON 应保存被编辑部件').toBeTruthy() | |||||
| expect(storedPart!.deleted).toBeFalsy() | |||||
| expect(storedPart!.position?.[0]).toBeCloseTo(data.modelPositionX, 3) | |||||
| expect(sumTimelineKeys(document)).toBe(expectedTimelineKeys) | |||||
| expect(document.blueprint?.nodes?.length).toBe(expectedBlueprintNodes) | |||||
| completed.push('选择、变换、软删除恢复、时间轴关键帧、蓝图节点均保存并刷新恢复') | |||||
| await captureScreenshot(page, testInfo, 'three-model-saved-and-restored') | |||||
| }) | |||||
| let publishedModelVersionId = '' | |||||
| await test.step('发布模型并成为场景目录资源', async () => { | |||||
| const editor = await waitForModelEditor(page) | |||||
| await editor.locator('[data-action="publish"]').click() | |||||
| const publishDialog = editor.locator('#publish-modal.open') | |||||
| await expect(publishDialog).toBeVisible() | |||||
| await expect(publishDialog.locator('.publish-check.failed')).toHaveCount(0) | |||||
| const publishResponsePromise = page.waitForResponse( | |||||
| (response) => isProjectMutation(response, 'POST', modelProjectId!, '/publish'), | |||||
| { timeout: 60_000 }, | |||||
| ) | |||||
| await publishDialog.locator('[data-action="publish-model-asset"]').click() | |||||
| await expectSuccessfulResponse(publishResponsePromise, '发布模型工程') | |||||
| await expect(editor.locator('.toast.success').filter({ hasText: '已发布' }).last()).toBeVisible({ timeout: 30_000 }) | |||||
| const detail = await readContentDetail(request, apiSession!, modelProjectId!) | |||||
| expect(detail.project.status).toBe('PUBLISHED') | |||||
| expect(detail.project.publishedVersionId).toBe(detail.currentVersion.id) | |||||
| publishedModelVersionId = detail.currentVersion.id | |||||
| completed.push('模型发布成功并具有精确 publishedVersionId,可进入场景发布目录') | |||||
| await captureScreenshot(page, testInfo, 'three-model-published') | |||||
| }) | |||||
| let workshopPartName = '' | |||||
| await test.step('新建场景,切换真实车间并验证部件软删除刷新恢复', async () => { | |||||
| sceneProjectId = await createContentProject(page, data.scene) | |||||
| let editor = await waitForSceneEditor(page) | |||||
| completed.push('场景编辑器无 iframe,原生 scene-editor-canvas 正常显示') | |||||
| await editor.locator('.panel-tabs button').filter({ hasText: '模板' }).click() | |||||
| await editor.locator('.template-card').filter({ hasText: '真实维修车间' }).click() | |||||
| await confirmMessageBox(page, '替换场景') | |||||
| await editor.locator('.panel-tabs button').filter({ hasText: '场景树' }).click() | |||||
| await expect.poll(() => editor.locator('.tree-row.child').count(), { | |||||
| message: '真实维修车间应展开大量可编辑部件', | |||||
| timeout: 60_000, | |||||
| }).toBeGreaterThanOrEqual(70) | |||||
| const part = editor.locator('.tree-row.child').first() | |||||
| workshopPartName = (await part.locator('b').textContent())?.trim() ?? '' | |||||
| expect(workshopPartName).not.toBe('') | |||||
| await part.click() | |||||
| await editor.locator('.danger-zone button.danger').click() | |||||
| await expect(sceneTreeRow(editor, workshopPartName)).toHaveClass(/deleted/) | |||||
| await saveScene(page, editor, sceneProjectId!) | |||||
| await page.reload() | |||||
| editor = await waitForSceneEditor(page) | |||||
| const deletedPart = sceneTreeRow(editor, workshopPartName) | |||||
| await expect(deletedPart).toHaveClass(/deleted/) | |||||
| await deletedPart.click() | |||||
| await expect(editor.locator('.restore-notice')).toContainText('软删除状态会随工程保存') | |||||
| await captureScreenshot(page, testInfo, 'three-scene-workshop-part-deleted-after-refresh') | |||||
| await editor.locator('.restore-notice button').click() | |||||
| await expect(sceneTreeRow(editor, workshopPartName)).not.toHaveClass(/deleted/) | |||||
| completed.push('真实维修车间部件软删除保存,刷新不复活,并可显式恢复') | |||||
| }) | |||||
| await test.step('从已发布模型目录放置模型、编辑变换并保存刷新', async () => { | |||||
| let editor = await waitForSceneEditor(page) | |||||
| await editor.locator('.panel-tabs button').filter({ hasText: '资源库' }).click() | |||||
| const publishedModelCard = editor.locator('.asset-card').filter({ hasText: data.model.name }) | |||||
| await expect(publishedModelCard, '场景资源库应出现刚发布的模型').toBeVisible({ timeout: 30_000 }) | |||||
| await publishedModelCard.dblclick() | |||||
| await editor.locator('.panel-tabs button').filter({ hasText: '场景树' }).click() | |||||
| const placedModel = sceneTreeRow(editor, data.model.name) | |||||
| await expect(placedModel).toBeVisible({ timeout: 60_000 }) | |||||
| await expect(editor.locator('.loading-overlay')).toHaveCount(0, { timeout: 60_000 }) | |||||
| await placedModel.click() | |||||
| const transformSection = editor.locator('.property-section').filter({ hasText: '变换' }).first() | |||||
| const scenePositionX = transformSection.locator('.vector-field').first().locator('input').first() | |||||
| await scenePositionX.fill(String(data.scenePositionX)) | |||||
| await scenePositionX.press('Tab') | |||||
| await editor.locator('.inspector-tabs button').filter({ hasText: '语义' }).click() | |||||
| const semanticSection = editor.locator('.property-section').filter({ hasText: '业务语义' }).first() | |||||
| const deviceInput = semanticSection.locator('label').filter({ hasText: '设备编号' }).locator('input') | |||||
| await deviceInput.fill(data.sceneDeviceId) | |||||
| await deviceInput.press('Tab') | |||||
| const reference = editor.locator('.reference-card') | |||||
| await expect(reference).toContainText(modelProjectId!) | |||||
| await expect(reference).toContainText(publishedModelVersionId) | |||||
| await expect(reference).not.toContainText('blob:') | |||||
| await captureScreenshot(page, testInfo, 'three-scene-published-model-reference') | |||||
| await saveScene(page, editor, sceneProjectId!) | |||||
| await page.reload() | |||||
| editor = await waitForSceneEditor(page) | |||||
| await sceneTreeRow(editor, data.model.name).click() | |||||
| const restoredTransform = editor.locator('.property-section').filter({ hasText: '变换' }).first() | |||||
| await expect(restoredTransform.locator('.vector-field').first().locator('input').first()).toHaveValue(String(data.scenePositionX)) | |||||
| await expect(sceneTreeRow(editor, workshopPartName)).not.toHaveClass(/deleted/) | |||||
| await editor.locator('.inspector-tabs button').filter({ hasText: '语义' }).click() | |||||
| await expect(editor.locator('.reference-card')).toContainText(publishedModelVersionId) | |||||
| const detail = await readContentDetail(request, apiSession!, sceneProjectId!) | |||||
| const document = detail.currentVersion.content as SceneDocument | |||||
| expect(document.schema).toBe('unreal-tran.scene') | |||||
| expect(document.version).toBe('2.0') | |||||
| const referencedModel = document.objects?.find((item) => item.targetProjectId === modelProjectId) | |||||
| expect(referencedModel, '场景 JSON 应保存已发布模型目录引用').toBeTruthy() | |||||
| expect(referencedModel!.targetVersionId).toBe(publishedModelVersionId) | |||||
| expect(referencedModel!.assetCode).toBe(uploadedAsset.code) | |||||
| expect(referencedModel!.assetUrl).toMatch(/^content:\/\/sha256\/[a-f0-9]{64}$/) | |||||
| expect(referencedModel!.assetUrl).not.toContain('blob:') | |||||
| expect(referencedModel!.position?.[0]).toBeCloseTo(data.scenePositionX, 3) | |||||
| expect(referencedModel!.semantic?.deviceId).toBe(data.sceneDeviceId) | |||||
| const restoredWorkshopPart = document.objects?.find((item) => item.name === workshopPartName && item.type === 'workshop-part') | |||||
| expect(restoredWorkshopPart?.deleted).toBeFalsy() | |||||
| expect(restoredWorkshopPart?.visible).toBeTruthy() | |||||
| const dependency = detail.currentVersion.dependencies.find((item) => item.relationType === 'SCENE_MODEL') | |||||
| expect(dependency, '场景版本应生成 SCENE_MODEL 精确依赖').toBeTruthy() | |||||
| expect(dependency!.targetProjectId).toBe(modelProjectId) | |||||
| expect(dependency!.targetVersionId).toBe(publishedModelVersionId) | |||||
| expect(dependency!.required).toBeTruthy() | |||||
| completed.push('发布模型目录引用、放置、变换、设备语义、精确版本依赖均保存并刷新恢复') | |||||
| await captureScreenshot(page, testInfo, 'three-scene-saved-and-restored') | |||||
| }) | |||||
| await test.step('执行八项场景校验并发布', async () => { | |||||
| const editor = await waitForSceneEditor(page) | |||||
| await editor.getByRole('button', { name: '发布检查', exact: true }).click() | |||||
| const validation = editor.locator('.validation-grid button') | |||||
| await expect(validation).toHaveCount(8) | |||||
| await expect(editor.locator('.validation-grid .fail')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, 'three-scene-eight-checks-passed') | |||||
| const publishResponsePromise = page.waitForResponse( | |||||
| (response) => isProjectMutation(response, 'POST', sceneProjectId!, '/publish'), | |||||
| { timeout: 60_000 }, | |||||
| ) | |||||
| await editor.locator('.publish-button').click() | |||||
| await expectSuccessfulResponse(publishResponsePromise, '发布场景工程') | |||||
| await expect(page.locator('.el-message--success').filter({ hasText: /场景发布完成|当前工程版本已发布/ }).last()).toBeVisible({ timeout: 30_000 }) | |||||
| const detail = await readContentDetail(request, apiSession!, sceneProjectId!) | |||||
| expect(detail.project.status).toBe('PUBLISHED') | |||||
| expect(detail.project.publishedVersionId).toBe(detail.currentVersion.id) | |||||
| completed.push('场景八项校验全部通过并发布,服务端状态为 PUBLISHED') | |||||
| await captureScreenshot(page, testInfo, 'three-scene-published') | |||||
| }) | |||||
| } catch (error) { | |||||
| testFailure = error | |||||
| } finally { | |||||
| if (apiSession) { | |||||
| cleanup.push(await removeContentProject(request, apiSession, '场景工程', sceneProjectId, data.scene.code)) | |||||
| cleanup.push(await removeContentProject(request, apiSession, '模型工程', modelProjectId, data.model.code)) | |||||
| } else { | |||||
| cleanup.push({ kind: '场景工程', id: sceneProjectId, code: data.scene.code, outcome: sceneProjectId ? 'failed' : 'not-created', message: 'API 会话未建立' }) | |||||
| cleanup.push({ kind: '模型工程', id: modelProjectId, code: data.model.code, outcome: modelProjectId ? 'failed' : 'not-created', message: 'API 会话未建立' }) | |||||
| } | |||||
| await closeContentApiSession(request, apiSession) | |||||
| const cleanupFailure = cleanup.find((item) => item.outcome === 'failed') | |||||
| await testInfo.attach('三维编辑器测试数据清理结果', { | |||||
| body: Buffer.from(`${JSON.stringify({ | |||||
| 测试前缀: data.prefix, | |||||
| 清理顺序: ['场景工程', '模型工程'], | |||||
| 结果: cleanup, | |||||
| 说明: '服务端工程为软删除;内容寻址物理对象可跨版本复用,不做破坏性强删。', | |||||
| }, null, 2)}\n`, 'utf8'), | |||||
| contentType: 'application/json; charset=utf-8', | |||||
| }) | |||||
| await testInfo.attach('三维编辑器E2E执行摘要', { | |||||
| body: Buffer.from(markdownReport( | |||||
| completed, | |||||
| cleanup, | |||||
| { model: modelProjectId, scene: sceneProjectId }, | |||||
| testFailure === undefined, | |||||
| ), 'utf8'), | |||||
| contentType: 'text/markdown; charset=utf-8', | |||||
| }) | |||||
| if (testFailure || cleanupFailure) { | |||||
| throw new AggregateError( | |||||
| [testFailure, cleanupFailure ? new Error(`测试数据清理失败:${cleanupFailure.kind} ${cleanupFailure.code}`) : undefined] | |||||
| .filter((item): item is unknown => item !== undefined), | |||||
| cleanupFailure ? '三维编辑器 E2E 未完成,且存在测试数据清理失败。' : '三维编辑器 E2E 未完成。', | |||||
| ) | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -0,0 +1,158 @@ | |||||
| import type { APIRequestContext } from '@playwright/test' | |||||
| import { adminCredentials } from './helpers' | |||||
| export interface ContentAssetRecord { | |||||
| id: string | |||||
| versionId: string | |||||
| code: string | |||||
| name: string | |||||
| type: string | |||||
| storageUri: string | |||||
| mimeType: string | |||||
| sizeBytes: number | |||||
| sha256: string | |||||
| metadata: Record<string, unknown> | |||||
| status: string | |||||
| sortOrder: number | |||||
| } | |||||
| export interface ContentDependencyRecord { | |||||
| id: string | |||||
| targetProjectId: string | |||||
| targetVersionId: string | |||||
| relationType: string | |||||
| required: boolean | |||||
| sortOrder: number | |||||
| } | |||||
| export interface ContentDetailRecord { | |||||
| project: { | |||||
| id: string | |||||
| type: string | |||||
| code: string | |||||
| name: string | |||||
| status: string | |||||
| currentVersionId: string | |||||
| publishedVersionId: string | null | |||||
| version: number | |||||
| } | |||||
| currentVersion: { | |||||
| id: string | |||||
| projectId: string | |||||
| versionNo: number | |||||
| versionCode: string | |||||
| status: string | |||||
| content: Record<string, unknown> | |||||
| dependencies: ContentDependencyRecord[] | |||||
| assets: ContentAssetRecord[] | |||||
| } | |||||
| } | |||||
| interface ApiEnvelope<T> { | |||||
| data?: T | |||||
| } | |||||
| export interface ContentApiSession { | |||||
| headers: Record<string, string> | |||||
| } | |||||
| export type CleanupOutcome = 'removed' | 'not-found' | 'failed' | 'not-created' | |||||
| export interface CleanupRecord { | |||||
| kind: '模型工程' | '场景工程' | |||||
| id: string | null | |||||
| code: string | |||||
| outcome: CleanupOutcome | |||||
| statusCode?: number | |||||
| message?: string | |||||
| } | |||||
| const endpoint = (projectId: string) => `/api/tran/v1/content/projects/${encodeURIComponent(projectId)}` | |||||
| const readData = async <T>(response: Awaited<ReturnType<APIRequestContext['get']>>, operation: string): Promise<T> => { | |||||
| if (!response.ok()) throw new Error(`${operation}失败(HTTP ${response.status()})`) | |||||
| const body = await response.json() as ApiEnvelope<T> | |||||
| if (body.data === undefined) throw new Error(`${operation}失败:响应未包含 data`) | |||||
| return body.data | |||||
| } | |||||
| export async function openContentApiSession(request: APIRequestContext): Promise<ContentApiSession> { | |||||
| const credentials = adminCredentials() | |||||
| const response = await request.post('/api/auth/v1/auth/login', { | |||||
| data: { | |||||
| username: credentials.username, | |||||
| password: credentials.password, | |||||
| roleCode: 'admin', | |||||
| rememberMe: false, | |||||
| }, | |||||
| }) | |||||
| const body = await readData<{ accessToken?: string }>(response, 'E2E API 登录') | |||||
| if (!body.accessToken) throw new Error('E2E API 登录失败:响应未返回访问令牌') | |||||
| return { headers: { Authorization: `Bearer ${body.accessToken}` } } | |||||
| } | |||||
| export async function closeContentApiSession( | |||||
| request: APIRequestContext, | |||||
| session: ContentApiSession | null, | |||||
| ): Promise<void> { | |||||
| if (!session) return | |||||
| await request.post('/api/auth/v1/auth/logout', { headers: session.headers }).catch(() => undefined) | |||||
| } | |||||
| export async function readContentDetail( | |||||
| request: APIRequestContext, | |||||
| session: ContentApiSession, | |||||
| projectId: string, | |||||
| ): Promise<ContentDetailRecord> { | |||||
| const response = await request.get(endpoint(projectId), { headers: session.headers }) | |||||
| return readData<ContentDetailRecord>(response, '读取内容工程') | |||||
| } | |||||
| export async function removeContentProject( | |||||
| request: APIRequestContext, | |||||
| session: ContentApiSession, | |||||
| kind: CleanupRecord['kind'], | |||||
| projectId: string | null, | |||||
| code: string, | |||||
| ): Promise<CleanupRecord> { | |||||
| if (!projectId) return { kind, id: null, code, outcome: 'not-created' } | |||||
| try { | |||||
| const detailResponse = await request.get(endpoint(projectId), { headers: session.headers }) | |||||
| if (detailResponse.status() === 404) return { kind, id: projectId, code, outcome: 'not-found' } | |||||
| if (!detailResponse.ok()) { | |||||
| return { | |||||
| kind, | |||||
| id: projectId, | |||||
| code, | |||||
| outcome: 'failed', | |||||
| statusCode: detailResponse.status(), | |||||
| message: '清理前读取工程失败', | |||||
| } | |||||
| } | |||||
| const detailBody = await detailResponse.json() as ApiEnvelope<ContentDetailRecord> | |||||
| const projectVersion = detailBody.data?.project.version | |||||
| if (!Number.isInteger(projectVersion)) { | |||||
| return { kind, id: projectId, code, outcome: 'failed', message: '清理前未取得工程乐观锁版本' } | |||||
| } | |||||
| const deleteResponse = await request.delete(endpoint(projectId), { | |||||
| headers: session.headers, | |||||
| params: { version: String(projectVersion) }, | |||||
| }) | |||||
| if (deleteResponse.ok()) return { kind, id: projectId, code, outcome: 'removed' } | |||||
| if (deleteResponse.status() === 404) return { kind, id: projectId, code, outcome: 'not-found' } | |||||
| return { | |||||
| kind, | |||||
| id: projectId, | |||||
| code, | |||||
| outcome: 'failed', | |||||
| statusCode: deleteResponse.status(), | |||||
| message: '服务端拒绝删除测试工程', | |||||
| } | |||||
| } catch { | |||||
| return { kind, id: projectId, code, outcome: 'failed', message: '清理 API 调用异常' } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,367 @@ | |||||
| import { expect, test, type Page, type Route } from '@playwright/test' | |||||
| import fs from 'node:fs' | |||||
| import path from 'node:path' | |||||
| /** | |||||
| * 作业指导书全流程回归:编制 → 送审 → 审批时间线 → 预览 → 预检 → 批准 → 发布 → 交付物下载。 | |||||
| * | |||||
| * 内容制作接口按 wiki/11-内容制作模块.md 的契约打桩(与 teaching-module 系列同一做法), | |||||
| * 验证前端流程、生命周期流转请求、预检口径与浏览器内交付物生成,不依赖数据库与在线服务。 | |||||
| * 打桩的状态流转表与服务端 REVIEW_TRANSITIONS 一致,契约分叉会在这里暴露。 | |||||
| */ | |||||
| type Json = Record<string, unknown> | |||||
| const SHOTS = path.resolve('artifacts/content-guide-lifecycle') | |||||
| fs.mkdirSync(SHOTS, { recursive: true }) | |||||
| const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-ofd' }) | |||||
| const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) | |||||
| const fail = (route: Route, status: number, message: string) => route.fulfill({ | |||||
| status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-ofd' }), | |||||
| }) | |||||
| const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 }) | |||||
| const GUIDE_IMAGE = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAADZSiLoAAAAGUlEQVR4nGP8z8DAwMDAxMDAwMDAAAALAAH+Q0KzAAAAAElFTkSuQmCC', 'base64') | |||||
| const step = (id: string, title: string): Json => ({ | |||||
| id, title, order: 1, | |||||
| summary: '确认整机停机并系统卸压后,按对角顺序拆除缸筒固定螺栓。', | |||||
| safety: '全程佩戴护目镜与防割手套,作业区设置警戒。', | |||||
| tools: '液压扳手、专用吊具', | |||||
| acceptance: '缸筒外观无划痕,接口无渗漏。', | |||||
| part: '支腿液压缸', anchor: '右前支腿默认视角', animation: '支腿拆卸', media: 'GUIDE_IMAGE_01', | |||||
| stepType: '拆卸', duration: '12:30', status: '已完成', hotspot: true, start: 0, end: 12, | |||||
| parameters: { 拧紧力矩: '220 N·m', 系统压力: '不高于 0.5 MPa', 环境温度: '5 至 35 摄氏度' }, | |||||
| }) | |||||
| const completeContent = (): Json => ({ | |||||
| equipment: 'GX-200 全地形起重机', | |||||
| owner: '整机维修保障中心', | |||||
| specification: 'GB/T 33190-2016', | |||||
| chapters: [ | |||||
| { id: 'c1', number: '01', title: '作业准备', order: 1, steps: [step('s1', '停机挂牌与卸压')] }, | |||||
| { id: 'c2', number: '02', title: '缸体拆卸', order: 2, steps: [step('s2', '拆卸支腿液压缸')] }, | |||||
| ], | |||||
| resources: [{ id: 'r1', assetCode: 'GUIDE_IMAGE_01', type: '图片', name: '支腿总成配图', status: 'READY' }], | |||||
| partsCatalog: [{ id: 'p1', name: '支腿液压缸' }], | |||||
| three: { parts: ['支腿液压缸'], markers: [], selectedPart: '支腿液压缸' }, | |||||
| publish: { version: 'V1.2.0', note: '首次正式发布', channels: ['OFD静态版式', '单机离线包'], publishedAt: '' }, | |||||
| }) | |||||
| const TRANSITIONS: Record<string, { from: string[]; to: string; commentRequired: boolean }> = { | |||||
| SUBMIT: { from: ['DRAFT', 'REJECTED'], to: 'REVIEW', commentRequired: false }, | |||||
| WITHDRAW: { from: ['REVIEW'], to: 'DRAFT', commentRequired: false }, | |||||
| APPROVE: { from: ['REVIEW'], to: 'APPROVED', commentRequired: false }, | |||||
| REJECT: { from: ['REVIEW'], to: 'REJECTED', commentRequired: true }, | |||||
| REVOKE: { from: ['PUBLISHED'], to: 'WITHDRAWN', commentRequired: true }, | |||||
| REVISE: { from: ['REJECTED', 'WITHDRAWN', 'SUPERSEDED'], to: 'DRAFT', commentRequired: false }, | |||||
| } | |||||
| class GuideMock { | |||||
| readonly requests: Array<{ method: string; path: string; body: Json }> = [] | |||||
| content: Json = completeContent() | |||||
| status = 'DRAFT' | |||||
| version = 3 | |||||
| publishedVersionId: string | null = null | |||||
| readCount = 0 | |||||
| downloadCount = 0 | |||||
| reviews: Json[] = [] | |||||
| private assets() { | |||||
| return [{ | |||||
| id: 'asset-image-01', versionId: '11', code: 'GUIDE_IMAGE_01', name: '支腿总成配图', | |||||
| type: 'IMAGE', storageUri: 'content://sha256/guide-image-01', mimeType: 'image/png', | |||||
| sizeBytes: GUIDE_IMAGE.length, sha256: 'a'.repeat(64), metadata: { format: 'PNG' }, status: 'READY', sortOrder: 0, | |||||
| }] | |||||
| } | |||||
| private project() { | |||||
| return { | |||||
| id: '901', type: 'GUIDE', code: 'OFD-2026-0031', name: '支腿液压缸中修作业指导书', | |||||
| description: '整机支腿液压缸中修标准作业', categoryCode: 'MAINTENANCE', coverUri: '', | |||||
| status: this.status, version: this.version, | |||||
| currentVersionId: '11', currentVersionNo: 3, currentVersionCode: 'V1.2.0', currentVersionStatus: 'DRAFT', | |||||
| publishedVersionId: this.publishedVersionId, | |||||
| ownerUserId: '7', ownerName: '内容作者', departmentId: '20', departmentName: '维修保障中心', | |||||
| addTime: 1786387200, updateTime: 1786387200, publishedTime: this.publishedVersionId ? 1786387200 : null, | |||||
| usageCount: 0, readCount: this.readCount, downloadCount: this.downloadCount, | |||||
| dependencyCount: 0, assetCount: this.assets().length, | |||||
| } | |||||
| } | |||||
| private version11(status = 'DRAFT') { | |||||
| return { | |||||
| id: '11', projectId: '901', versionNo: 3, versionCode: 'V1.2.0', status, | |||||
| content: this.content, dependencies: [], assets: this.assets(), | |||||
| createdByName: '内容作者', publishedByName: status === 'PUBLISHED' ? '内容作者' : '', | |||||
| publishedTime: status === 'PUBLISHED' ? 1786387200 : null, | |||||
| addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '', | |||||
| } | |||||
| } | |||||
| private detail() { | |||||
| return { project: this.project(), currentVersion: this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT') } | |||||
| } | |||||
| async install(page: Page) { | |||||
| await page.addInitScript(() => { | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-ofd-token') | |||||
| }) | |||||
| await page.route('**/api/auth/v1/auth/me', (route) => ok(route, { | |||||
| user: { id: '1', username: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 }, | |||||
| activeRoleId: 'role-admin', | |||||
| roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }], | |||||
| permissions: ['content.ofd', 'content.model', 'content.scene', 'content.training', 'content.create', 'content.update', 'content.publish', 'content.delete'], | |||||
| authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200, | |||||
| })) | |||||
| await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, [])) | |||||
| await page.route('**/api/tran/v1/content/**', (route) => this.handle(route)) | |||||
| } | |||||
| private async handle(route: Route) { | |||||
| const request = route.request() | |||||
| const url = new URL(request.url()) | |||||
| const target = url.pathname.replace(/^.*\/v1\/content/, '') | |||||
| const method = request.method() | |||||
| let body: Json = {} | |||||
| try { | |||||
| const text = request.postData() | |||||
| if (text) body = JSON.parse(text) as Json | |||||
| } catch { body = {} } | |||||
| this.requests.push({ method, path: target, body }) | |||||
| if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()])) | |||||
| if (method === 'GET' && target === '/projects/summary') { | |||||
| return ok(route, { total: 1, draft: 0, review: 0, published: 0, byType: [], byTrainingMode: [] }) | |||||
| } | |||||
| if (method === 'GET' && target === '/catalog') return ok(route, pageOf([])) | |||||
| if (method === 'GET' && target === '/projects/901') return ok(route, this.detail()) | |||||
| if (method === 'PUT' && target === '/projects/901') { | |||||
| if (body.content && typeof body.content === 'object') this.content = body.content as Json | |||||
| this.version += 1 | |||||
| return ok(route, this.detail()) | |||||
| } | |||||
| if (method === 'GET' && target === '/projects/901/versions') { | |||||
| return ok(route, pageOf([this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT')])) | |||||
| } | |||||
| if (method === 'GET' && target === '/projects/901/versions/11') { | |||||
| return ok(route, this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT')) | |||||
| } | |||||
| if (method === 'GET' && target === '/projects/901/assets/asset-image-01/content') { | |||||
| return route.fulfill({ status: 200, contentType: 'image/png', body: GUIDE_IMAGE }) | |||||
| } | |||||
| if (method === 'GET' && target === '/projects/901/reviews') return ok(route, [...this.reviews].reverse()) | |||||
| if (method === 'POST' && target === '/projects/901/review') { | |||||
| const action = String(body.action ?? '') | |||||
| const transition = TRANSITIONS[action] | |||||
| if (!transition) return fail(route, 400, '动作无效') | |||||
| if (!transition.from.includes(this.status)) return fail(route, 409, '起始状态不匹配') | |||||
| const comment = String(body.comment ?? '').trim() | |||||
| if (transition.commentRequired && !comment) return fail(route, 400, '该动作必须填写处理意见') | |||||
| this.reviews.push({ | |||||
| id: String(this.reviews.length + 1), projectId: '901', versionId: '11', action, | |||||
| fromStatus: this.status, toStatus: transition.to, comment, | |||||
| actorUserId: '1', actorName: '管理员', operateTime: 1786387200 + this.reviews.length * 60, | |||||
| }) | |||||
| this.status = transition.to | |||||
| this.version += 1 | |||||
| return ok(route, this.detail()) | |||||
| } | |||||
| if (method === 'POST' && target === '/projects/901/publish') { | |||||
| if (!['REVIEW', 'APPROVED'].includes(this.status)) return fail(route, 409, '指导书必须先提交复核,复核中或已批准才能发布') | |||||
| this.status = 'PUBLISHED' | |||||
| this.publishedVersionId = '11' | |||||
| this.version += 1 | |||||
| return ok(route, this.detail()) | |||||
| } | |||||
| if (method === 'POST' && target === '/projects/901/metrics') { | |||||
| if (String(body.metric) === 'READ') this.readCount += 1 | |||||
| if (String(body.metric) === 'DOWNLOAD') this.downloadCount += 1 | |||||
| return ok(route, {}) | |||||
| } | |||||
| return fail(route, 404, `未打桩的内容接口 ${method} ${target}`) | |||||
| } | |||||
| } | |||||
| const statValue = async (page: Page, label: string) => ( | |||||
| page.locator('.preflight-summary article', { hasText: label }).locator('b').first().innerText() | |||||
| ) | |||||
| test.describe('作业指导书生命周期与交付物', () => { | |||||
| test('编制 → 预览 → 预检 → 送审 → 批准 → 发布 → 交付物下载', async ({ page }) => { | |||||
| const mock = new GuideMock() | |||||
| await mock.install(page) | |||||
| const consoleErrors: string[] = [] | |||||
| page.on('pageerror', (error) => consoleErrors.push(error.message)) | |||||
| // 0. 资源导入:只能展示真实版本资产,并提供受控图片上传入口 | |||||
| await page.goto('/content/ofd/901/studio?view=resources') | |||||
| await expect(page.locator('button', { hasText: '上传配图' })).toBeVisible() | |||||
| const resourceTable = page.locator('.single-panel .el-table').first() | |||||
| await expect(resourceTable).toContainText('GUIDE_IMAGE_01') | |||||
| await expect(resourceTable).toContainText('已就绪') | |||||
| // 1. 编制:工作台读到草稿,能改内容并保存 | |||||
| await page.goto('/content/ofd/901/studio?view=editor') | |||||
| await expect(page.locator('.guide-flow-panel')).toBeVisible() | |||||
| await expect(page.locator('.chapter-panel')).toContainText('作业准备') | |||||
| await page.locator('.step-row', { hasText: '停机挂牌与卸压' }).first().click() | |||||
| const titleInput = page.locator('.step-editor .form-grid input').first() | |||||
| await titleInput.fill('停机挂牌与系统卸压') | |||||
| // el-input 的 change 在失焦时触发,脏标记要等失焦后才置位。 | |||||
| await titleInput.blur() | |||||
| await expect(page.locator('.studio-dirty')).toBeVisible() | |||||
| await page.getByRole('button', { name: /^保存$/ }).click() | |||||
| await expect(page.locator('.studio-dirty')).toHaveCount(0) | |||||
| const saved = mock.requests.filter((item) => item.method === 'PUT') | |||||
| expect(saved.length).toBeGreaterThan(0) | |||||
| await page.screenshot({ path: path.join(SHOTS, '01-编制.png'), fullPage: true }) | |||||
| // 2. 送审:再次修改但不手工保存,生命周期动作必须先保存最新草稿再发起 SUBMIT | |||||
| await titleInput.fill('停机挂牌、系统卸压与复核确认') | |||||
| await titleInput.blur() | |||||
| await expect(page.locator('.studio-dirty')).toBeVisible() | |||||
| const putsBeforeSubmit = mock.requests.filter((item) => item.method === 'PUT').length | |||||
| await page.getByRole('button', { name: '提交复核' }).click() | |||||
| await page.getByRole('button', { name: '确认提交复核' }).click() | |||||
| await expect.poll(() => mock.status).toBe('REVIEW') | |||||
| expect(mock.requests.filter((item) => item.method === 'PUT').length).toBe(putsBeforeSubmit + 1) | |||||
| expect(((mock.content.chapters as Json[])[0]!.steps as Json[])[0]!.title).toBe('停机挂牌、系统卸压与复核确认') | |||||
| await page.screenshot({ path: path.join(SHOTS, '02-送审.png'), fullPage: true }) | |||||
| // 3. 审批时间线读到刚才的流转 | |||||
| await page.goto('/content/ofd/901/studio?view=feedback') | |||||
| await expect(page.locator('.single-panel')).toContainText('提交复核') | |||||
| await expect(page.locator('.single-panel')).toContainText('草稿 → 审核中') | |||||
| await page.screenshot({ path: path.join(SHOTS, '03-审批时间线.png'), fullPage: true }) | |||||
| // 4. 预览:章节目录与正文来自当前草稿 | |||||
| await page.goto('/content/ofd/901/preview') | |||||
| await expect(page.locator('.guide-toc')).toContainText('缸体拆卸') | |||||
| await expect(page.locator('.guide-document')).toContainText('停机挂牌、系统卸压与复核确认') | |||||
| await page.screenshot({ path: path.join(SHOTS, '04-预览.png'), fullPage: true }) | |||||
| // 5. 预检:24 项内容检查 + 3 项平台检查全部通过 | |||||
| await page.goto('/content/ofd/901/preflight') | |||||
| await expect(page.locator('.preflight-summary')).toBeVisible() | |||||
| expect(await statValue(page, '通过')).toBe('26') | |||||
| expect(await statValue(page, '待确认')).toBe('1') | |||||
| expect(await statValue(page, '阻断')).toBe('0') | |||||
| expect(await statValue(page, '完成度')).toContain('100') | |||||
| const enterPublish = page.getByRole('button', { name: '进入发布确认' }) | |||||
| await expect(enterPublish).toBeEnabled() | |||||
| await page.screenshot({ path: path.join(SHOTS, '05-预检.png'), fullPage: true }) | |||||
| // 6. 批准 + 发布 | |||||
| await enterPublish.click() | |||||
| await expect(page).toHaveURL(/\/content\/ofd\/901\/publish$/) | |||||
| await page.getByRole('button', { name: '复核通过' }).click() | |||||
| await page.getByRole('button', { name: '确认复核通过' }).click() | |||||
| await expect.poll(() => mock.status).toBe('APPROVED') | |||||
| await page.getByRole('button', { name: '确认发布' }).click() | |||||
| await page.getByRole('button', { name: '确认发布' }).last().click() | |||||
| await expect.poll(() => mock.status).toBe('PUBLISHED') | |||||
| await expect(page.locator('.publish-confirmation h2')).toContainText('当前版本已发布') | |||||
| await page.screenshot({ path: path.join(SHOTS, '06-发布.png'), fullPage: true }) | |||||
| // 7. 交付物:OFD 静态版式与单机离线包都能生成、自校验通过并触发下载 | |||||
| const ofdDownload = page.waitForEvent('download') | |||||
| await page.getByRole('button', { name: 'OFD 静态版式' }).click() | |||||
| const ofdFile = await ofdDownload | |||||
| expect(ofdFile.suggestedFilename()).toMatch(/\.ofd$/) | |||||
| const ofdPath = path.join(SHOTS, 'delivery.ofd') | |||||
| await ofdFile.saveAs(ofdPath) | |||||
| const ofdBytes = fs.readFileSync(ofdPath) | |||||
| expect(ofdBytes.length).toBeGreaterThan(4096) | |||||
| expect(ofdBytes.subarray(0, 4).toString('hex')).toBe('504b0304') | |||||
| expect(ofdBytes.toString('latin1')).toContain('META-INF/EXPORT-METADATA.json') | |||||
| await expect(page.locator('.delivery-report')).toContainText('OFD 静态版式') | |||||
| await expect(page.locator('.delivery-report')).toContainText('发布版本 V1.2.0') | |||||
| const hash = await page.locator('.delivery-hash dd').innerText() | |||||
| expect(hash).toMatch(/^[a-f0-9]{64}$/) | |||||
| // 交付信息必须单列堆叠,否则文件名与 64 位哈希会被挤成折行。 | |||||
| const rows = page.locator('.delivery-report > div') | |||||
| const firstRow = await rows.nth(0).boundingBox() | |||||
| const secondRow = await rows.nth(1).boundingBox() | |||||
| expect(secondRow!.y).toBeGreaterThanOrEqual(firstRow!.y + firstRow!.height - 1) | |||||
| const offlineDownload = page.waitForEvent('download') | |||||
| await page.getByRole('button', { name: '单机离线包' }).click() | |||||
| const offlineFile = await offlineDownload | |||||
| expect(offlineFile.suggestedFilename()).toMatch(/离线阅读版\.html$/) | |||||
| const offlinePath = path.join(SHOTS, 'delivery-offline.html') | |||||
| await offlineFile.saveAs(offlinePath) | |||||
| const offlineHtml = fs.readFileSync(offlinePath, 'utf8') | |||||
| expect(offlineHtml.startsWith('<!doctype html>')).toBe(true) | |||||
| expect(offlineHtml).toContain('Content-Security-Policy') | |||||
| expect(offlineHtml).toContain('data-role="toc"') | |||||
| expect(offlineHtml.match(/data-step-page="\d+"/g)?.length).toBe(2) | |||||
| expect(offlineHtml.match(/data:image\/png;base64/g)?.length).toBe(2) | |||||
| expect(/<(?:script[^>]*src|link[^>]*href|img[^>]*src)=["']https?:/i.test(offlineHtml)).toBe(false) | |||||
| await page.screenshot({ path: path.join(SHOTS, '07-交付物.png'), fullPage: true }) | |||||
| // 8. 两次下载各回报一次 DOWNLOAD 统计 | |||||
| expect(mock.downloadCount).toBe(2) | |||||
| const metrics = mock.requests.filter((item) => item.path === '/projects/901/metrics') | |||||
| expect(metrics.map((item) => item.body.metric)).toEqual(['DOWNLOAD', 'DOWNLOAD']) | |||||
| expect(consoleErrors, `页面运行时错误:${consoleErrors.join(' | ')}`).toEqual([]) | |||||
| }) | |||||
| test('撤回发布必须填写处理意见', async ({ page }) => { | |||||
| const mock = new GuideMock() | |||||
| mock.status = 'PUBLISHED' | |||||
| mock.publishedVersionId = '11' | |||||
| await mock.install(page) | |||||
| await page.goto('/content/ofd/901/studio?view=editor') | |||||
| await page.getByRole('button', { name: '撤回发布' }).click() | |||||
| const dialog = page.locator('.el-dialog') | |||||
| await expect(dialog).toBeVisible() | |||||
| await dialog.getByRole('button', { name: '确认撤回发布' }).click() | |||||
| await expect(page.locator('.el-message')).toContainText('必须填写处理意见') | |||||
| expect(mock.status).toBe('PUBLISHED') | |||||
| await dialog.locator('textarea').fill('发现第 2 步拧紧力矩参数有误') | |||||
| await dialog.getByRole('button', { name: '确认撤回发布' }).click() | |||||
| await expect.poll(() => mock.status).toBe('WITHDRAWN') | |||||
| const record = mock.reviews.at(-1) | |||||
| expect(record?.action).toBe('REVOKE') | |||||
| expect(record?.comment).toBe('发现第 2 步拧紧力矩参数有误') | |||||
| await page.screenshot({ path: path.join(SHOTS, '08-撤回发布.png'), fullPage: true }) | |||||
| }) | |||||
| test('要素缺失时预检阻断并禁用发布确认', async ({ page }) => { | |||||
| const mock = new GuideMock() | |||||
| mock.status = 'REVIEW' | |||||
| const content = completeContent() | |||||
| const chapters = content.chapters as Json[] | |||||
| const steps = (chapters[0] as Json).steps as Json[] | |||||
| const target = steps[0] as Json | |||||
| delete target.safety | |||||
| delete target.acceptance | |||||
| target.hotspot = false | |||||
| target.parameters = { 拧紧力矩: '220 N·m' } | |||||
| mock.content = content | |||||
| await mock.install(page) | |||||
| await page.goto('/content/ofd/901/preflight') | |||||
| await expect(page.locator('.preflight-summary')).toBeVisible() | |||||
| expect(Number(await statValue(page, '阻断'))).toBeGreaterThan(0) | |||||
| await expect(page.getByRole('button', { name: '进入发布确认' })).toBeDisabled() | |||||
| await expect(page.locator('.check-list')).toContainText('安全要求完整') | |||||
| await expect(page.locator('.check-list')).toContainText('热点交互完整') | |||||
| await page.screenshot({ path: path.join(SHOTS, '09-预检阻断.png'), fullPage: true }) | |||||
| }) | |||||
| test('未发布草稿不能生成正式交付物', async ({ page }) => { | |||||
| const mock = new GuideMock() | |||||
| await mock.install(page) | |||||
| await page.goto('/content/ofd/901/publish') | |||||
| await expect(page.locator('.delivery-panel')).toContainText('完成复核与发布后方可生成正式交付物') | |||||
| await expect(page.getByRole('button', { name: 'OFD 静态版式' })).toBeDisabled() | |||||
| await expect(page.getByRole('button', { name: '单机离线包' })).toBeDisabled() | |||||
| await page.screenshot({ path: path.join(SHOTS, '10-草稿禁止交付.png'), fullPage: true }) | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,416 @@ | |||||
| import { expect, test, type Page, type Route } from '@playwright/test' | |||||
| import fs from 'node:fs' | |||||
| import path from 'node:path' | |||||
| /** | |||||
| * 训练编排工作台回归:科目与形态 → 场景与目标 → 训练步骤 → 判定规则 → 故障注入 → 评分与运行 → 发布校验。 | |||||
| * | |||||
| * 内容制作接口按 wiki/11-内容制作模块.md 的契约打桩(与 content-guide-lifecycle 同一做法)。 | |||||
| * 重点验证两件容易回归的事:步骤目标只能来自绑定场景的可操作对象; | |||||
| * 保存时必须产出 stepCode / actionCode / physicalEventRule.progressPercent, | |||||
| * 否则训练项目发布成教学任务时会被服务端 validRuntimeCode 拒绝。 | |||||
| */ | |||||
| type Json = Record<string, unknown> | |||||
| const SHOTS = path.resolve('artifacts/content-training-studio') | |||||
| fs.mkdirSync(SHOTS, { recursive: true }) | |||||
| const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-training' }) | |||||
| const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) | |||||
| const fail = (route: Route, status: number, message: string) => route.fulfill({ | |||||
| status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-training' }), | |||||
| }) | |||||
| const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 }) | |||||
| /** 绑定场景:两个可操作对象、一个环境设施、一个已软删除对象。 */ | |||||
| const sceneContent = (): Json => ({ | |||||
| schema: 'unreal-tran.scene', | |||||
| version: '2.0', | |||||
| objects: [ | |||||
| { | |||||
| id: 'o1', name: '支腿液压缸总成', visible: true, deleted: false, | |||||
| semantic: { category: '液压执行机构', deviceId: 'DEV-01', interactionId: 'cylinder-main', operable: true, capabilities: ['select', 'move', 'inspect', 'measure'], tags: [] }, | |||||
| }, | |||||
| { | |||||
| id: 'o2', name: '活塞密封圈', visible: true, deleted: false, | |||||
| semantic: { category: '密封件', deviceId: 'DEV-02', interactionId: 'seal-ring', operable: true, capabilities: ['select', 'move'], tags: [] }, | |||||
| }, | |||||
| { | |||||
| id: 'o3', name: '车间地面', visible: true, deleted: false, | |||||
| semantic: { category: '环境设施', deviceId: '', interactionId: '', operable: false, capabilities: [], tags: [] }, | |||||
| }, | |||||
| { | |||||
| id: 'o4', name: '历史对象', visible: true, deleted: true, | |||||
| semantic: { category: '液压', deviceId: 'DEV-04', interactionId: 'ghost', operable: true, capabilities: ['select'], tags: [] }, | |||||
| }, | |||||
| ], | |||||
| }) | |||||
| const legacyTrainingContent = (mode: string): Json => ({ | |||||
| level: '中级', | |||||
| durationMinutes: 25, | |||||
| totalScore: 100, | |||||
| modeConfig: mode === 'CONFRONTATION' | |||||
| ? { type: 'CONFRONTATION', redTeamName: '红方', blueTeamName: '蓝方', teamSize: 3, roundDurationMinutes: 30, submitPositions: ['COMMANDER'], objective: '限时排除故障', winRule: '' } | |||||
| : { type: mode }, | |||||
| // 历史工程只有 title/summary/score,缺 stepCode/actionCode/目标,归一化后必须补齐编码。 | |||||
| steps: [ | |||||
| { id: 'step_1c9f0e2a-8f11-4c0b-9d6e-77a1f3b5c9d0', title: '检查缸体外观', summary: '目视检查缸筒是否存在划痕', score: 40 }, | |||||
| { id: 'step_2d8e1f3b-7a22-4b1c-8e5f-66b2e4c6d8e1', title: '拆卸密封圈', summary: '按对角顺序拆除固定螺栓', score: 60 }, | |||||
| ], | |||||
| }) | |||||
| class TrainingMock { | |||||
| readonly requests: Array<{ method: string; path: string; body: Json }> = [] | |||||
| mode = 'VIRTUAL' | |||||
| content: Json = legacyTrainingContent('VIRTUAL') | |||||
| version = 2 | |||||
| sceneBound = true | |||||
| private dependencies() { | |||||
| return this.sceneBound | |||||
| ? [{ | |||||
| targetProjectId: '701', targetVersionId: '25', relationType: 'TRAINING_SCENE', required: true, | |||||
| targetType: 'SCENE', targetCode: 'SC-2026-014', targetName: '支腿检修车间', targetVersionNumber: 3, | |||||
| }] | |||||
| : [] | |||||
| } | |||||
| private project() { | |||||
| return { | |||||
| id: '901', type: 'TRAINING', code: 'TR-2026-071', name: '支腿液压缸中修训练', | |||||
| description: '整机支腿液压缸中修标准训练', categoryCode: 'MEDIUM_REPAIR', coverUri: '', | |||||
| status: 'DRAFT', trainingMode: this.mode, version: this.version, | |||||
| currentVersionId: '31', currentVersionNo: 2, currentVersionCode: 'V1.1.0', currentVersionStatus: 'DRAFT', | |||||
| publishedVersionId: null, ownerUserId: '7', ownerName: '内容作者', | |||||
| departmentId: '20', departmentName: '维修保障中心', | |||||
| addTime: 1786387200, updateTime: 1786387200, publishedTime: null, | |||||
| usageCount: 0, readCount: 0, downloadCount: 0, | |||||
| dependencyCount: this.dependencies().length, assetCount: 0, | |||||
| } | |||||
| } | |||||
| private detail() { | |||||
| return { | |||||
| project: this.project(), | |||||
| currentVersion: { | |||||
| id: '31', projectId: '901', versionNo: 2, versionCode: 'V1.1.0', status: 'DRAFT', | |||||
| content: this.content, dependencies: this.dependencies(), assets: [], | |||||
| createdByName: '内容作者', publishedByName: '', publishedTime: null, | |||||
| addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '', | |||||
| }, | |||||
| } | |||||
| } | |||||
| async install(page: Page) { | |||||
| await page.addInitScript(() => { | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-training-token') | |||||
| }) | |||||
| await page.route('**/api/auth/v1/auth/me', (route) => ok(route, { | |||||
| user: { id: '1', username: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 }, | |||||
| activeRoleId: 'role-admin', | |||||
| roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }], | |||||
| permissions: ['content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish', 'content.delete'], | |||||
| authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200, | |||||
| })) | |||||
| await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, [])) | |||||
| await page.route('**/api/tran/v1/content/**', (route) => this.handle(route)) | |||||
| } | |||||
| private async handle(route: Route) { | |||||
| const request = route.request() | |||||
| const url = new URL(request.url()) | |||||
| const target = url.pathname.replace(/^.*\/v1\/content/, '') | |||||
| const method = request.method() | |||||
| let body: Json = {} | |||||
| try { | |||||
| const text = request.postData() | |||||
| if (text) body = JSON.parse(text) as Json | |||||
| } catch { body = {} } | |||||
| this.requests.push({ method, path: target, body }) | |||||
| if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()])) | |||||
| if (method === 'GET' && target === '/projects/summary') { | |||||
| return ok(route, { total: 1, draft: 1, review: 0, published: 0, byType: [], byTrainingMode: [] }) | |||||
| } | |||||
| if (method === 'GET' && target === '/catalog') return ok(route, pageOf([])) | |||||
| if (method === 'GET' && target === '/projects/901') return ok(route, this.detail()) | |||||
| if (method === 'PUT' && target === '/projects/901') { | |||||
| if (body.content && typeof body.content === 'object') this.content = body.content as Json | |||||
| this.version += 1 | |||||
| return ok(route, this.detail()) | |||||
| } | |||||
| if (method === 'GET' && target === '/projects/901/versions') return ok(route, pageOf([this.detail().currentVersion])) | |||||
| if (method === 'GET' && target === '/projects/901/versions/31') return ok(route, this.detail().currentVersion) | |||||
| if (method === 'GET' && target === '/projects/901/reviews') return ok(route, []) | |||||
| // 绑定场景的已发布版本,工作台据此解析可操作目标 | |||||
| if (method === 'GET' && target === '/projects/701/versions/25') { | |||||
| return ok(route, { | |||||
| id: '25', projectId: '701', versionNo: 3, versionCode: 'V1.2.0', status: 'PUBLISHED', | |||||
| content: sceneContent(), dependencies: [], assets: [], | |||||
| createdByName: '内容作者', publishedByName: '内容作者', publishedTime: 1786387200, | |||||
| addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '', | |||||
| }) | |||||
| } | |||||
| if (method === 'POST' && target === '/projects/901/metrics') return ok(route, {}) | |||||
| return fail(route, 404, `未打桩的内容接口 ${method} ${target}`) | |||||
| } | |||||
| /** 最后一次保存提交的 content,用于断言工作台真正回写了什么。 */ | |||||
| savedContent(): Json { | |||||
| const last = [...this.requests].reverse().find((item) => item.method === 'PUT') | |||||
| return (last?.body.content ?? {}) as Json | |||||
| } | |||||
| savedSteps(): Json[] { | |||||
| const content = this.savedContent() | |||||
| return Array.isArray(content.steps) ? content.steps as Json[] : [] | |||||
| } | |||||
| } | |||||
| const summaryValue = (page: Page, label: string) => ( | |||||
| page.locator('.preflight-summary article', { hasText: label }).locator('b').first().innerText() | |||||
| ) | |||||
| test.describe('训练编排工作台', () => { | |||||
| test('绑定场景解析目标、步骤编排与保存产出运行时编码', async ({ page }) => { | |||||
| const mock = new TrainingMock() | |||||
| await mock.install(page) | |||||
| const pageErrors: string[] = [] | |||||
| page.on('pageerror', (error) => pageErrors.push(error.message)) | |||||
| // 1. 场景与目标:只有可操作且未删除的对象进入目标清单 | |||||
| await page.goto('/content/training-projects/901/studio?view=scene') | |||||
| await expect(page.locator('.training-flow-panel')).toBeVisible() | |||||
| await expect(page.locator('.scene-facts')).toContainText('支腿检修车间') | |||||
| await expect(page.locator('.scene-facts')).toContainText('2 个') | |||||
| const targetRows = page.locator('.single-panel tbody tr') | |||||
| await expect(targetRows).toHaveCount(2) | |||||
| await expect(page.locator('.single-panel tbody')).toContainText('cylinder-main') | |||||
| await expect(page.locator('.single-panel tbody')).toContainText('seal-ring') | |||||
| await expect(page.locator('.single-panel tbody')).not.toContainText('车间地面') | |||||
| await expect(page.locator('.single-panel tbody')).not.toContainText('历史对象') | |||||
| await page.screenshot({ path: path.join(SHOTS, '01-场景与目标.png'), fullPage: true }) | |||||
| // 2. 训练步骤:历史工程的两步已被补上派生编码 | |||||
| await page.goto('/content/training-projects/901/studio?view=steps') | |||||
| const stepRows = page.locator('.step-row') | |||||
| await expect(stepRows).toHaveCount(2) | |||||
| await expect(stepRows.first()).toContainText('VIRTUAL-S01') | |||||
| await expect(stepRows.nth(1)).toContainText('VIRTUAL-S02') | |||||
| // 3. 选中首步,关联目标;密封圈只支持拆卸/装配/检查,不出现测量 | |||||
| await stepRows.first().click() | |||||
| await expect(page.locator('.inspector-panel')).toContainText('目标与动作') | |||||
| const targetSelect = page.locator('.inspector-panel .el-form-item', { hasText: '作业目标' }).locator('.el-select').first() | |||||
| await targetSelect.click() | |||||
| await page.locator('.el-select-dropdown__item', { hasText: 'cylinder-main' }).first().click() | |||||
| await expect(page.locator('.step-row').first()).toContainText('支腿液压缸总成') | |||||
| await expect(page.locator('.studio-dirty')).toBeVisible() | |||||
| await stepRows.nth(1).click() | |||||
| const secondTarget = page.locator('.inspector-panel .el-form-item', { hasText: '作业目标' }).locator('.el-select').first() | |||||
| await secondTarget.click() | |||||
| await page.locator('.el-select-dropdown__item', { hasText: 'seal-ring' }).first().click() | |||||
| const modeSelect = page.locator('.inspector-panel .el-form-item', { hasText: '操作模式' }).locator('.el-select').first() | |||||
| await modeSelect.click() | |||||
| const modeOptions = page.locator('.el-select-dropdown:visible .el-select-dropdown__item') | |||||
| // 密封圈的 capabilities 只有 select + move,所以没有「测量」这一项 | |||||
| await expect(modeOptions).toHaveCount(3) | |||||
| await expect(modeOptions.filter({ hasText: '测量' })).toHaveCount(0) | |||||
| await modeOptions.filter({ hasText: '拆卸' }).first().click() | |||||
| await expect(page.locator('.inspector-panel .el-form-item', { hasText: '动作类型' }).locator('input')).toHaveValue('三维拖拽') | |||||
| await page.screenshot({ path: path.join(SHOTS, '02-步骤编排.png'), fullPage: true }) | |||||
| // 4. 新增步骤后顺序与编码连续 | |||||
| await page.getByRole('button', { name: '新增步骤' }).click() | |||||
| await expect(page.locator('.step-row')).toHaveCount(3) | |||||
| await expect(page.locator('.step-row').nth(2)).toContainText('VIRTUAL-S03') | |||||
| // 5. 保存:payload 必须带 stepCode / actionCode,且不再出现超长 uuid 编码 | |||||
| await page.getByRole('button', { name: /^保存$/ }).click() | |||||
| await expect(page.locator('.studio-dirty')).toHaveCount(0) | |||||
| const saved = mock.savedSteps() | |||||
| expect(saved).toHaveLength(3) | |||||
| expect(saved.map((step) => step.stepCode)).toEqual(['VIRTUAL-S01', 'VIRTUAL-S02', 'VIRTUAL-S03']) | |||||
| expect(saved.map((step) => step.actionCode)).toEqual(['VIRTUAL-A01', 'VIRTUAL-A02', 'VIRTUAL-A03']) | |||||
| for (const step of saved) { | |||||
| // 与服务端 TrainingPreflightValidator / TeachingAssignmentService 的 validRuntimeCode 同一正则 | |||||
| expect(String(step.stepCode)).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/) | |||||
| expect(String(step.actionCode)).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/) | |||||
| } | |||||
| expect(saved[0]?.targetId).toBe('cylinder-main') | |||||
| expect(saved[1]?.targetId).toBe('seal-ring') | |||||
| expect(saved[1]?.mode).toBe('remove') | |||||
| expect(saved[1]?.interaction).toBe('spatial-drag') | |||||
| // 6. 发布校验:第三步没有目标,必须阻断并能定位 | |||||
| await page.goto('/content/training-projects/901/studio?view=preflight') | |||||
| await expect(page.locator('.preflight-summary')).toBeVisible() | |||||
| expect(Number(await summaryValue(page, '阻断'))).toBeGreaterThan(0) | |||||
| // 第一个 .issue-list 是阻断项,第二个是提示项 | |||||
| await expect(page.locator('.issue-list').first()).toContainText('尚未关联场景交互对象') | |||||
| await page.screenshot({ path: path.join(SHOTS, '03-发布校验.png'), fullPage: true }) | |||||
| expect(pageErrors, `页面运行时错误:${pageErrors.join(' | ')}`).toEqual([]) | |||||
| }) | |||||
| test('未固定场景时目标不可选并阻断发布', async ({ page }) => { | |||||
| const mock = new TrainingMock() | |||||
| mock.sceneBound = false | |||||
| await mock.install(page) | |||||
| await page.goto('/content/training-projects/901/studio?view=scene') | |||||
| await expect(page.locator('.el-alert')).toContainText('尚未固定必选场景版本') | |||||
| await page.goto('/content/training-projects/901/studio?view=preflight') | |||||
| await expect(page.locator('.issue-list').first()).toContainText('发布前必须固定一个必选的已发布场景版本') | |||||
| await page.screenshot({ path: path.join(SHOTS, '04-未绑定场景.png'), fullPage: true }) | |||||
| }) | |||||
| test('实装形态自动生成严格递增的事件进度', async ({ page }) => { | |||||
| const mock = new TrainingMock() | |||||
| mock.mode = 'PHYSICAL' | |||||
| mock.content = legacyTrainingContent('PHYSICAL') | |||||
| await mock.install(page) | |||||
| await page.goto('/content/training-projects/901/studio?view=steps') | |||||
| await expect(page.locator('.step-row')).toHaveCount(2) | |||||
| await page.locator('.step-row').first().click() | |||||
| await expect(page.locator('.inspector-panel')).toContainText('实装事件规则') | |||||
| await expect(page.locator('.inspector-panel .el-form-item', { hasText: '完成后进度' }).locator('input')).toHaveValue('50%') | |||||
| await page.locator('.step-row').nth(1).click() | |||||
| await expect(page.locator('.inspector-panel .el-form-item', { hasText: '完成后进度' }).locator('input')).toHaveValue('100%') | |||||
| await page.getByRole('button', { name: '新增步骤' }).click() | |||||
| await page.getByRole('button', { name: /^保存$/ }).click() | |||||
| await expect(page.locator('.studio-dirty')).toHaveCount(0) | |||||
| const saved = mock.savedSteps() | |||||
| const progress = saved.map((step) => Number((step.physicalEventRule as Json).progressPercent)) | |||||
| expect(progress).toEqual([33, 67, 100]) | |||||
| for (const step of saved) { | |||||
| const rule = step.physicalEventRule as Json | |||||
| expect(['DEVICE', 'VISION', 'MANUAL']).toContain(String(rule.source)) | |||||
| expect(String(rule.eventType)).toMatch(/^[A-Za-z][A-Za-z0-9._-]{1,99}$/) | |||||
| } | |||||
| await page.screenshot({ path: path.join(SHOTS, '05-实装进度.png'), fullPage: true }) | |||||
| }) | |||||
| test('对抗形态校验每队人数、岗位与可提交岗位', async ({ page }) => { | |||||
| const mock = new TrainingMock() | |||||
| mock.mode = 'CONFRONTATION' | |||||
| mock.content = legacyTrainingContent('CONFRONTATION') | |||||
| await mock.install(page) | |||||
| await page.goto('/content/training-projects/901/studio?view=overview') | |||||
| await expect(page.locator('.single-panel')).toContainText('每队人数') | |||||
| await expect(page.locator('.single-panel')).toContainText('可提交结果的岗位') | |||||
| await page.goto('/content/training-projects/901/studio?view=steps') | |||||
| await page.locator('.step-row').first().click() | |||||
| await expect(page.locator('.inspector-panel')).toContainText('执行岗位') | |||||
| // 历史工程的对抗步骤没有岗位,发布校验必须逐步骤阻断 | |||||
| await page.goto('/content/training-projects/901/studio?view=preflight') | |||||
| await expect(page.locator('.issue-list').first()).toContainText('尚未指定执行岗位') | |||||
| await page.screenshot({ path: path.join(SHOTS, '06-对抗校验.png'), fullPage: true }) | |||||
| }) | |||||
| test('高级编排从线性流程派生并能加分支节点与时间轴', async ({ page }) => { | |||||
| const mock = new TrainingMock() | |||||
| await mock.install(page) | |||||
| await page.goto('/content/training-projects/901/studio?view=flow') | |||||
| // 内容制作页面已去掉 SystemPageHeader,视图名称由各视图面板自己的标题承担 | |||||
| await expect(page.locator('.studio-main')).toContainText('流程与时间轴') | |||||
| await expect(page.locator('button', { hasText: '上传音频' })).toBeVisible() | |||||
| // 历史工程没有 authoring,归一化必须派生出 开始 + 2 个步骤 + 结束 | |||||
| const flowNodes = page.locator('.flow-node') | |||||
| await expect(flowNodes).toHaveCount(4) | |||||
| await expect(page.locator('.flow-node.type-start')).toHaveCount(1) | |||||
| await expect(page.locator('.flow-node.type-end')).toHaveCount(1) | |||||
| await expect(page.locator('.flow-edges line')).toHaveCount(3) | |||||
| // 派生的线性流程全部可达,不应出现不可达标记 | |||||
| await expect(page.locator('.flow-node.unreachable')).toHaveCount(0) | |||||
| // 时间轴按步骤派生,讲解音频列按 assetCode 引用 | |||||
| const timelineRows = page.locator('.system-panel > .el-table tbody tr') | |||||
| await expect(timelineRows).toHaveCount(2) | |||||
| await expect(page.getByRole('columnheader', { name: '讲解资源编码' })).toBeVisible() | |||||
| await expect(timelineRows.first().locator('.el-select')).toHaveCount(1) | |||||
| // 新增条件分支节点并连线,节点数与连线数同步增长 | |||||
| await page.getByRole('button', { name: '条件分支' }).click() | |||||
| await expect(flowNodes).toHaveCount(5) | |||||
| await expect(page.locator('.flow-node.active')).toContainText('条件分支') | |||||
| await expect(page.locator('.flow-detail')).toContainText('判定表达式') | |||||
| // 新节点尚未连线,必须被标成不可达 | |||||
| await expect(page.locator('.flow-node.unreachable')).toHaveCount(1) | |||||
| // 两个 select 的 popper 都留在 DOM 里,按文本取会命中已关闭的那个, | |||||
| // 改用键盘在当前打开的下拉里定位:选项顺序就是 authoring.nodes 顺序 | |||||
| const selects = page.locator('.flow-edge-form .el-select') | |||||
| const pickOption = async (index: number, ordinal: number) => { | |||||
| await selects.nth(index).click() | |||||
| for (let i = 0; i < ordinal; i++) await page.keyboard.press('ArrowDown') | |||||
| await page.keyboard.press('Enter') | |||||
| } | |||||
| await pickOption(0, 1) // 训练开始 | |||||
| await pickOption(1, 5) // 条件分支 1 | |||||
| await page.getByRole('button', { name: '连线' }).click() | |||||
| await expect(page.locator('.flow-edges line')).toHaveCount(4) | |||||
| await expect(page.locator('.flow-node.unreachable')).toHaveCount(0) | |||||
| await expect(page.locator('.studio-dirty')).toBeVisible() | |||||
| // 保存后高级编排必须回写到 content.authoring | |||||
| await page.getByRole('button', { name: /^保存$/ }).click() | |||||
| await expect(page.locator('.studio-dirty')).toHaveCount(0) | |||||
| const authoring = mock.savedContent().authoring as Json | |||||
| expect(String(authoring.schema)).toBe('unreal-tran.training-authoring/v1') | |||||
| expect((authoring.nodes as Json[]).length).toBe(5) | |||||
| expect((authoring.edges as Json[]).length).toBe(4) | |||||
| const tracks = (authoring.timeline as Json).tracks as Record<string, Json> | |||||
| expect(Object.keys(tracks)).toHaveLength(2) | |||||
| await page.screenshot({ path: path.join(SHOTS, '08-高级编排.png'), fullPage: true }) | |||||
| }) | |||||
| test('九个流程视图与工具、故障、评分视图均可渲染', async ({ page }) => { | |||||
| const mock = new TrainingMock() | |||||
| await mock.install(page) | |||||
| const pageErrors: string[] = [] | |||||
| page.on('pageerror', (error) => pageErrors.push(error.message)) | |||||
| await page.goto('/content/training-projects/901/studio?view=overview') | |||||
| const navButtons = page.locator('.training-flow-group button') | |||||
| await expect(navButtons).toHaveCount(9) | |||||
| for (const [view, marker] of [ | |||||
| ['overview', '科目与形态'], | |||||
| ['scene', '场景与目标'], | |||||
| ['steps', '训练步骤'], | |||||
| ['flow', '流程与时间轴'], | |||||
| ['motion', '工具与动画'], | |||||
| ['rules', '判定规则'], | |||||
| ['faults', '故障注入'], | |||||
| ['scoring', '评分与运行'], | |||||
| ['preflight', '发布校验'], | |||||
| ] as const) { | |||||
| await page.goto(`/content/training-projects/901/studio?view=${view}`) | |||||
| await expect(page.locator('.studio-main')).toContainText(marker) | |||||
| } | |||||
| // 故障与工装新增后能被步骤引用 | |||||
| await page.goto('/content/training-projects/901/studio?view=faults') | |||||
| await page.getByRole('button', { name: '新增故障' }).click() | |||||
| await expect(page.locator('.single-panel tbody tr')).toHaveCount(1) | |||||
| await page.goto('/content/training-projects/901/studio?view=motion') | |||||
| await page.getByRole('button', { name: '新增工装' }).click() | |||||
| // 工装名称落在 input 的 value 上,textContent 取不到 | |||||
| await expect(page.locator('.single-panel tbody input').nth(1)).toHaveValue('工装 1') | |||||
| await page.screenshot({ path: path.join(SHOTS, '07-工具与故障.png'), fullPage: true }) | |||||
| expect(pageErrors, `页面运行时错误:${pageErrors.join(' | ')}`).toEqual([]) | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,148 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| /** | |||||
| * 内容制作列表工具栏回归。 | |||||
| * | |||||
| * 工具栏用显式筛选:关键词与三个下拉都要点「搜索」或在输入框回车才生效,「重置」清空全部条件; | |||||
| * 不放「刷新」按钮,也不重复显示总条数(条数由底部分页承担)。 | |||||
| * 关键词框比全局 390px 收窄三分之一到 260px,给下拉与按钮留横向空间。 | |||||
| * 「新建」动作落在工具栏右端,页面不使用 SystemPageHeader,白底内容区有最小高度。 | |||||
| */ | |||||
| const json = (route: Route, data: unknown) => route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ code: 0, message: '成功', data, timestamp: Date.now(), requestId: 'e2e-toolbar' }), | |||||
| }) | |||||
| /** 记录每次列表查询的 keyword 与 status,用于断言即时筛选与防抖。 */ | |||||
| type ListQuery = { keyword: string; status: string } | |||||
| async function mockContentListApi(page: Page, queries: ListQuery[]) { | |||||
| await page.route(/\/api\/(?:auth|tran)\//, async (route) => { | |||||
| const url = new URL(route.request().url()) | |||||
| const path = url.pathname | |||||
| if (path.endsWith('/auth/me')) { | |||||
| return json(route, { | |||||
| userId: '1', | |||||
| username: 'admin', | |||||
| displayName: '系统管理员', | |||||
| departmentId: '110', | |||||
| departmentName: '信息中心', | |||||
| activeRoleId: '1', | |||||
| authorizationMode: 'SINGLE_ACTIVE', | |||||
| mustChangePassword: false, | |||||
| roles: [{ id: '1', code: 'ADMIN', name: '管理员', shortName: '管', enabled: true, builtIn: true, isSuperAdmin: true, dataScopeCode: 'ALL', version: 1 }], | |||||
| // 组件按 content.create / content.update / content.publish 判断动作权限, | |||||
| // 页面权限用 content.model;两套都要给,否则「新建」会退化成只读标签 | |||||
| permissions: ['content.model', 'content.create', 'content.update', 'content.delete', 'content.publish'], | |||||
| }) | |||||
| } | |||||
| if (path.endsWith('/content/projects/summary')) { | |||||
| return json(route, { total: 3, draft: 1, review: 0, published: 2, byType: [], byTrainingMode: [] }) | |||||
| } | |||||
| if (path.endsWith('/content/projects')) { | |||||
| queries.push({ | |||||
| keyword: url.searchParams.get('keyword') ?? '', | |||||
| status: url.searchParams.get('status') ?? '', | |||||
| }) | |||||
| return json(route, { records: [], total: 0, page: 1, size: 10 }) | |||||
| } | |||||
| if (path.endsWith('/auth/ping')) return json(route, { service: 'ut-auth', port: 6101, status: 'UP' }) | |||||
| if (path.endsWith('/tran/ping')) return json(route, { service: 'ut-tran', port: 6102, status: 'UP' }) | |||||
| return json(route, {}) | |||||
| }) | |||||
| } | |||||
| test('内容列表工具栏显式搜索重置、无刷新按钮且筛选项左对齐', async ({ page }, testInfo) => { | |||||
| const queries: ListQuery[] = [] | |||||
| await page.setViewportSize({ width: 1920, height: 900 }) | |||||
| await mockContentListApi(page, queries) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('unreal-tran:web:access-token:v1', 'e2e-mock-token') | |||||
| }) | |||||
| await page.goto('/content/models') | |||||
| // 页头卡已去掉,没有 h1 可等;以列表卡片作为就绪锚点 | |||||
| await expect(page.locator('.content-list-card')).toBeVisible() | |||||
| const toolbar = page.locator('.content-filter') | |||||
| // 工具栏用显式的搜索与重置,不放刷新按钮 | |||||
| await expect(toolbar.getByRole('button', { name: '刷新' })).toHaveCount(0) | |||||
| const searchButton = toolbar.getByRole('button', { name: '搜索' }) | |||||
| const resetButton = toolbar.getByRole('button', { name: '重置' }) | |||||
| await expect(searchButton).toBeVisible() | |||||
| await expect(resetButton).toBeVisible() | |||||
| // 页头卡已去掉,「新建」动作落在工具条右端;总条数由底部分页承担,工具条里不再重复 | |||||
| await expect(page.locator('.system-page-header')).toHaveCount(0) | |||||
| await expect(toolbar.locator('.list-result-count')).toHaveCount(0) | |||||
| const createButton = toolbar.getByRole('button', { name: '新建模型' }) | |||||
| await expect(createButton).toBeVisible() | |||||
| const keywordInput = toolbar.locator('.el-input').first() | |||||
| const [toolbarBox, keywordBox, searchBox, resetBox, createBox] = await Promise.all([ | |||||
| toolbar.boundingBox(), | |||||
| keywordInput.boundingBox(), | |||||
| searchButton.boundingBox(), | |||||
| resetButton.boundingBox(), | |||||
| createButton.boundingBox(), | |||||
| ]) | |||||
| for (const box of [toolbarBox, keywordBox, searchBox, resetBox, createBox]) expect(box).not.toBeNull() | |||||
| // 关键词框比全局 390px 收窄三分之一 | |||||
| expect(keywordBox!.width).toBeGreaterThan(230) | |||||
| expect(keywordBox!.width).toBeLessThanOrEqual(270) | |||||
| // 搜索、重置按内容宽度显示,且和筛选控件一起靠左,不被 flex-grow 撑开 | |||||
| expect(searchBox!.width).toBeLessThanOrEqual(110) | |||||
| expect(resetBox!.width).toBeLessThanOrEqual(110) | |||||
| expect(resetBox!.x + resetBox!.width).toBeLessThan(toolbarBox!.x + toolbarBox!.width - 200) | |||||
| // 新建贴住工具条右端 | |||||
| expect(createBox!.x + createBox!.width).toBeGreaterThan(toolbarBox!.x + toolbarBox!.width - 40) | |||||
| // 白底内容区有最小高度,数据少时下方不出现大片空白 | |||||
| const cardBox = await page.locator('.content-list-card').boundingBox() | |||||
| expect(cardBox!.height).toBeGreaterThan(600) | |||||
| // 空态提供「清除筛选」,与工具栏的重置同一行为 | |||||
| const empty = page.locator('.asset-row-empty') | |||||
| await expect(empty).toContainText('没有符合条件的模型') | |||||
| await expect(empty.getByRole('button', { name: '清除筛选' })).toBeVisible() | |||||
| await captureScreenshot(page, testInfo, 'content-filter-explicit') | |||||
| // 只输入关键词不查询,必须点搜索或回车才发请求 | |||||
| const keywordRequests = () => queries.filter((item) => item.keyword === '挖掘机').length | |||||
| await toolbar.locator('input').first().fill('挖掘机') | |||||
| await page.waitForTimeout(700) | |||||
| expect(keywordRequests(), '未点搜索前不应发出查询').toBe(0) | |||||
| await searchButton.click() | |||||
| await expect.poll(keywordRequests, { timeout: 5_000 }).toBeGreaterThanOrEqual(1) | |||||
| // 下拉切换同样要点搜索才生效 | |||||
| const statusRequests = () => queries.filter((item) => item.status === 'PUBLISHED').length | |||||
| await toolbar.locator('.el-select').first().click() | |||||
| await page.getByRole('option', { name: '已发布', exact: true }).click() | |||||
| await page.waitForTimeout(500) | |||||
| expect(statusRequests(), '未点搜索前状态筛选不应生效').toBe(0) | |||||
| await searchButton.click() | |||||
| await expect.poll(statusRequests, { timeout: 5_000 }).toBeGreaterThanOrEqual(1) | |||||
| // 重置清空关键词并重新查询 | |||||
| await resetButton.click() | |||||
| await expect(toolbar.locator('input').first()).toHaveValue('') | |||||
| await expect.poll(() => queries.at(-1)?.keyword ?? 'x', { timeout: 5_000 }).toBe('') | |||||
| await page.setViewportSize({ width: 760, height: 900 }) | |||||
| await page.reload() | |||||
| await expect(page.locator('.content-list-card')).toBeVisible() | |||||
| const responsiveToolbar = page.locator('.content-filter') | |||||
| const hasHorizontalOverflow = await responsiveToolbar.evaluate((element) => element.scrollWidth > element.clientWidth + 1) | |||||
| expect(hasHorizontalOverflow).toBe(false) | |||||
| const responsiveSearchWidth = await responsiveToolbar.getByRole('button', { name: '搜索' }) | |||||
| .evaluate((element) => element.getBoundingClientRect().width) | |||||
| expect(responsiveSearchWidth).toBeLessThanOrEqual(110) | |||||
| await captureScreenshot(page, testInfo, 'content-filter-responsive') | |||||
| }) | |||||
| @@ -0,0 +1,67 @@ | |||||
| import fs from 'node:fs' | |||||
| import path from 'node:path' | |||||
| import { fileURLToPath } from 'node:url' | |||||
| import { expect, test as base, type Page, type TestInfo } from '@playwright/test' | |||||
| const screenshotDirectory = fileURLToPath(new URL('../artifacts/screenshots/', import.meta.url)) | |||||
| const safeName = (value: string) => value | |||||
| .normalize('NFKC') | |||||
| .replace(/[^\p{L}\p{N}._-]+/gu, '-') | |||||
| .replace(/^-+|-+$/g, '') | |||||
| .slice(0, 120) | |||||
| const stableTestName = (testInfo: TestInfo) => { | |||||
| const fileStem = path.basename(testInfo.file, path.extname(testInfo.file)) | |||||
| return safeName(`${testInfo.project.name}--${fileStem}--${testInfo.title}`).slice(0, 110) || 'unnamed-test' | |||||
| } | |||||
| export async function captureScreenshot(page: Page, testInfo: TestInfo, label: string) { | |||||
| fs.mkdirSync(screenshotDirectory, { recursive: true }) | |||||
| const testName = stableTestName(testInfo) | |||||
| const artifactName = safeName(label).slice(0, 70) || 'success' | |||||
| const screenshotPath = path.join(screenshotDirectory, `${testName}--${artifactName}.png`) | |||||
| await page.screenshot({ | |||||
| path: screenshotPath, | |||||
| fullPage: true, | |||||
| animations: 'disabled', | |||||
| caret: 'hide', | |||||
| }) | |||||
| await testInfo.attach(`success-${artifactName}`, { path: screenshotPath, contentType: 'image/png' }) | |||||
| return screenshotPath | |||||
| } | |||||
| export const test = base | |||||
| test.beforeEach(async ({}, testInfo) => { | |||||
| fs.mkdirSync(screenshotDirectory, { recursive: true }) | |||||
| const prefix = `${stableTestName(testInfo)}--` | |||||
| for (const filename of fs.readdirSync(screenshotDirectory)) { | |||||
| if (filename.startsWith(prefix) && filename.endsWith('.png')) { | |||||
| fs.rmSync(path.join(screenshotDirectory, filename), { force: true }) | |||||
| } | |||||
| } | |||||
| }) | |||||
| test.afterEach(async ({ page }, testInfo) => { | |||||
| if (testInfo.status === testInfo.expectedStatus || page.isClosed()) return | |||||
| fs.mkdirSync(screenshotDirectory, { recursive: true }) | |||||
| const title = safeName(testInfo.titlePath.join('--')) || 'failed-test' | |||||
| const filename = `${title}--retry-${testInfo.retry}--${Date.now()}.png` | |||||
| const screenshotPath = path.join(screenshotDirectory, filename) | |||||
| try { | |||||
| await page.screenshot({ path: screenshotPath, fullPage: true }) | |||||
| await testInfo.attach('failure-screenshot', { path: screenshotPath, contentType: 'image/png' }) | |||||
| } catch (error) { | |||||
| await testInfo.attach('failure-screenshot-error', { | |||||
| body: Buffer.from(String(error)), | |||||
| contentType: 'text/plain', | |||||
| }) | |||||
| } | |||||
| }) | |||||
| export { expect } | |||||
| @@ -0,0 +1,139 @@ | |||||
| import type { Locator, Page } from '@playwright/test' | |||||
| import { expect } from './fixtures' | |||||
| export interface Credentials { | |||||
| username: string | |||||
| password: string | |||||
| roleCode?: 'admin' | 'administrative' | 'teacher' | 'student' | |||||
| } | |||||
| const roleLabels = { | |||||
| admin: '管理员', | |||||
| administrative: '行政', | |||||
| teacher: '教员', | |||||
| student: '学员', | |||||
| } as const | |||||
| const readRequired = (name: string) => { | |||||
| const value = process.env[name]?.trim() | |||||
| if (!value) throw new Error(`缺少环境变量 ${name};请参考 ute2e/.env.example 配置测试凭据。`) | |||||
| return value | |||||
| } | |||||
| const readOptional = (name: string) => process.env[name]?.trim() || '' | |||||
| export const missingLoginOrganizationEnvironment = (roleCode: Credentials['roleCode']) => { | |||||
| if (roleCode === 'administrative') { | |||||
| return readOptional('E2E_LOGIN_DEPARTMENT_NAME') ? [] : ['E2E_LOGIN_DEPARTMENT_NAME'] | |||||
| } | |||||
| if (roleCode === 'teacher') { | |||||
| return [ | |||||
| !readOptional('E2E_LOGIN_PARENT_DEPARTMENT_NAME') && 'E2E_LOGIN_PARENT_DEPARTMENT_NAME', | |||||
| !readOptional('E2E_LOGIN_DEPARTMENT_NAME') && 'E2E_LOGIN_DEPARTMENT_NAME', | |||||
| ].filter((name): name is string => Boolean(name)) | |||||
| } | |||||
| return [] | |||||
| } | |||||
| const selectVisibleOption = async (select: Locator, requestedLabel: string, aliases: string[] = []) => { | |||||
| await expect(select).toBeVisible() | |||||
| await expect(select).toBeEnabled() | |||||
| const availableLabels = (await select.locator('option:not([disabled])').allTextContents()).map((label) => label.trim()) | |||||
| const selectedLabel = [requestedLabel, ...aliases].find((label) => availableLabels.includes(label)) | |||||
| if (!selectedLabel) { | |||||
| throw new Error(`登录组织选项中找不到“${requestedLabel}”;当前可选项:${availableLabels.join('、') || '无'}。`) | |||||
| } | |||||
| await select.selectOption({ label: selectedLabel }) | |||||
| } | |||||
| export async function selectLoginOrganization(page: Page, roleCode: Credentials['roleCode']) { | |||||
| if (roleCode !== 'administrative' && roleCode !== 'teacher') return | |||||
| const missingEnvironment = missingLoginOrganizationEnvironment(roleCode) | |||||
| if (missingEnvironment.length) { | |||||
| throw new Error(`角色“${roleLabels[roleCode]}”登录需要配置 ${missingEnvironment.join('、')}。`) | |||||
| } | |||||
| const departmentName = readOptional('E2E_LOGIN_DEPARTMENT_NAME') | |||||
| if (roleCode === 'administrative') { | |||||
| await selectVisibleOption(page.getByTestId('login-department'), departmentName) | |||||
| return | |||||
| } | |||||
| const parentDepartmentName = readOptional('E2E_LOGIN_PARENT_DEPARTMENT_NAME') | |||||
| await selectVisibleOption(page.getByTestId('login-organization'), parentDepartmentName) | |||||
| await selectVisibleOption( | |||||
| page.getByTestId('login-department'), | |||||
| departmentName, | |||||
| departmentName === parentDepartmentName ? [`${departmentName}(本级)`] : [], | |||||
| ) | |||||
| } | |||||
| export const adminCredentials = (): Credentials => ({ | |||||
| username: readRequired('E2E_ADMIN_USERNAME'), | |||||
| password: readRequired('E2E_ADMIN_PASSWORD'), | |||||
| roleCode: 'admin', | |||||
| }) | |||||
| export async function login(page: Page, credentials: Credentials) { | |||||
| await page.goto('/login') | |||||
| const roleCode = credentials.roleCode ?? 'admin' | |||||
| await page.locator(`[data-role-code="${roleCode}"]`).click() | |||||
| await selectLoginOrganization(page, roleCode) | |||||
| await page.locator('input[name="username"]').fill(credentials.username) | |||||
| await page.locator('input[name="password"]').fill(credentials.password) | |||||
| await page.getByRole('button', { name: /登录系统|正在验证身份/ }).click() | |||||
| await expect(page).not.toHaveURL(/\/login(?:\?|$)/) | |||||
| await expect(page.locator('.platform-shell')).toBeVisible() | |||||
| } | |||||
| export async function loginAsAdmin(page: Page) { | |||||
| await login(page, adminCredentials()) | |||||
| } | |||||
| export const visibleDialog = (page: Page) => page.locator('.el-dialog:visible').last() | |||||
| export const formItem = (scope: Locator, label: string | RegExp) => scope | |||||
| .locator('.el-form-item') | |||||
| .filter({ hasText: label }) | |||||
| .first() | |||||
| export async function fillFormItem(scope: Locator, label: string | RegExp, value: string) { | |||||
| const control = formItem(scope, label).locator('input:not([type="hidden"]), textarea').first() | |||||
| await expect(control).toBeVisible() | |||||
| await control.fill(value) | |||||
| } | |||||
| export async function chooseSelectOption( | |||||
| page: Page, | |||||
| scope: Locator, | |||||
| label: string | RegExp, | |||||
| option: string | RegExp, | |||||
| ) { | |||||
| const item = formItem(scope, label) | |||||
| await item.locator('.el-select').click() | |||||
| const dropdown = page.locator('.el-select-dropdown:visible').last() | |||||
| await expect(dropdown).toBeVisible() | |||||
| await dropdown.locator('.el-select-dropdown__item').filter({ hasText: option }).first().click() | |||||
| } | |||||
| export async function confirmMessageBox(page: Page, buttonName: string | RegExp) { | |||||
| const box = page.locator('.el-message-box:visible').last() | |||||
| await expect(box).toBeVisible() | |||||
| await box.getByRole('button', { name: buttonName }).click() | |||||
| } | |||||
| export async function dismissOverlays(page: Page) { | |||||
| for (let index = 0; index < 3; index += 1) await page.keyboard.press('Escape') | |||||
| } | |||||
| export const tableRowByText = (page: Page, text: string) => page | |||||
| .locator('.el-table__body-wrapper .el-table__row') | |||||
| .filter({ hasText: text }) | |||||
| .first() | |||||
| export const roleCardByText = (page: Page, text: string) => page | |||||
| .locator('.roles-table .el-table__body-wrapper .el-table__row') | |||||
| .filter({ hasText: text }) | |||||
| .first() | |||||
| @@ -0,0 +1,316 @@ | |||||
| import type { Page } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const loginContext = { | |||||
| defaultRoleCode: 'admin', | |||||
| roles: [ | |||||
| { | |||||
| code: 'administrative', | |||||
| label: '行政', | |||||
| accountLabel: '行政账号', | |||||
| organizationMode: 'SINGLE', | |||||
| organizationLabels: ['教学系'], | |||||
| }, | |||||
| { | |||||
| code: 'teacher', | |||||
| label: '教员', | |||||
| accountLabel: '教员账号', | |||||
| organizationMode: 'CASCADE', | |||||
| organizationLabels: ['教学系', '教研室'], | |||||
| }, | |||||
| { | |||||
| code: 'student', | |||||
| label: '学员', | |||||
| accountLabel: '学号或账号', | |||||
| organizationMode: 'NONE', | |||||
| organizationLabels: [], | |||||
| }, | |||||
| { | |||||
| code: 'admin', | |||||
| label: '管理员', | |||||
| accountLabel: '管理员账号', | |||||
| organizationMode: 'NONE', | |||||
| organizationLabels: [], | |||||
| }, | |||||
| ], | |||||
| departments: [ | |||||
| { | |||||
| id: '100', | |||||
| code: 'EQUIPMENT', | |||||
| name: '工程装备系', | |||||
| parentId: null, | |||||
| children: [ | |||||
| { | |||||
| id: '110', | |||||
| code: 'MAINTENANCE', | |||||
| name: '维修教研室', | |||||
| parentId: '100', | |||||
| children: [], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| { | |||||
| id: '200', | |||||
| code: 'COMMAND', | |||||
| name: '指挥系', | |||||
| parentId: null, | |||||
| children: [], | |||||
| }, | |||||
| ], | |||||
| } as const | |||||
| const loginIdentities = { | |||||
| administrative: [ | |||||
| { id: '301', displayName: '秦主任', departmentId: '100', departmentName: '工程装备系' }, | |||||
| ], | |||||
| teacher: [ | |||||
| { id: '401', displayName: '许教员', departmentId: '100', departmentName: '工程装备系' }, | |||||
| { id: '402', displayName: '杜晨', departmentId: '110', departmentName: '维修教研室' }, | |||||
| ], | |||||
| student: [ | |||||
| { id: '501', displayName: '学生甲', departmentId: '110', departmentName: '维修教研室' }, | |||||
| ], | |||||
| } as const | |||||
| const envelope = (data: unknown, requestId: string) => JSON.stringify({ | |||||
| code: 200, | |||||
| message: '成功', | |||||
| data, | |||||
| timestamp: '2026-08-17T00:00:00+08:00', | |||||
| requestId, | |||||
| }) | |||||
| interface RuntimeIssues { | |||||
| consoleErrors: string[] | |||||
| pageErrors: string[] | |||||
| } | |||||
| const watchRuntimeIssues = (page: Page): RuntimeIssues => { | |||||
| const issues: RuntimeIssues = { consoleErrors: [], pageErrors: [] } | |||||
| page.on('console', (message) => { | |||||
| if (message.type() === 'error') issues.consoleErrors.push(message.text()) | |||||
| }) | |||||
| page.on('pageerror', (error) => issues.pageErrors.push(error.message)) | |||||
| return issues | |||||
| } | |||||
| const mockLoginPage = async (page: Page) => { | |||||
| await page.route('**/api/auth/v1/system-config/public', (route) => route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: envelope({ | |||||
| systemName: '某类装备维修实训数字车间', | |||||
| logoConfigured: false, | |||||
| logoUrl: null, | |||||
| faviconConfigured: false, | |||||
| faviconUrl: null, | |||||
| }, 'login-visual-public-config'), | |||||
| })) | |||||
| await page.route('**/api/auth/v1/auth/login-context', (route) => route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: envelope(loginContext, 'login-visual-context'), | |||||
| })) | |||||
| await page.route('**/api/auth/v1/auth/login-identities**', (route) => { | |||||
| const url = new URL(route.request().url()) | |||||
| const roleCode = url.searchParams.get('roleCode') | |||||
| const departmentId = url.searchParams.get('departmentId') | |||||
| const source = roleCode && roleCode in loginIdentities | |||||
| ? loginIdentities[roleCode as keyof typeof loginIdentities] | |||||
| : [] | |||||
| const identities = source.filter((identity) => !departmentId || identity.departmentId === departmentId) | |||||
| return route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: envelope(identities, 'login-visual-identities'), | |||||
| }) | |||||
| }) | |||||
| } | |||||
| const openLoginPage = async (page: Page) => { | |||||
| await mockLoginPage(page) | |||||
| await page.goto('/login') | |||||
| const contextState = page.locator('.login-context-state') | |||||
| await expect(contextState).toHaveText('身份认证服务正常') | |||||
| await expect(contextState).toHaveAttribute('aria-live', 'polite') | |||||
| await expect(contextState).toHaveCSS('position', 'absolute') | |||||
| await expect(contextState).toHaveCSS('clip-path', 'inset(50%)') | |||||
| } | |||||
| const expectNoHorizontalOverflow = async (page: Page) => { | |||||
| const geometry = await page.evaluate(() => { | |||||
| const card = document.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect() | |||||
| return { | |||||
| documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, | |||||
| bodyOverflow: document.body.scrollWidth - document.body.clientWidth, | |||||
| cardLeft: card?.left ?? -1, | |||||
| cardRight: card?.right ?? Number.POSITIVE_INFINITY, | |||||
| viewportWidth: window.innerWidth, | |||||
| } | |||||
| }) | |||||
| expect(geometry.documentOverflow).toBeLessThanOrEqual(1) | |||||
| expect(geometry.bodyOverflow).toBeLessThanOrEqual(1) | |||||
| expect(geometry.cardLeft).toBeGreaterThanOrEqual(0) | |||||
| expect(geometry.cardRight).toBeLessThanOrEqual(geometry.viewportWidth + 1) | |||||
| } | |||||
| const expectNoRuntimeIssues = (issues: RuntimeIssues) => { | |||||
| expect(issues.consoleErrors, `console.error: ${issues.consoleErrors.join('\n')}`).toEqual([]) | |||||
| expect(issues.pageErrors, `pageerror: ${issues.pageErrors.join('\n')}`).toEqual([]) | |||||
| } | |||||
| test.describe('新原型登录页视觉与安全交互回归', () => { | |||||
| test('1920x945 展示新背景、校标、450x500 卡片且 SSO 只提示未接入', async ({ page }, testInfo) => { | |||||
| await page.setViewportSize({ width: 1920, height: 945 }) | |||||
| const issues = watchRuntimeIssues(page) | |||||
| let loginRequestCount = 0 | |||||
| page.on('request', (request) => { | |||||
| const url = new URL(request.url()) | |||||
| if (request.method() === 'POST' && url.pathname === '/api/auth/v1/auth/login') loginRequestCount += 1 | |||||
| }) | |||||
| await openLoginPage(page) | |||||
| await expect(page.locator('.login-page')).toBeVisible() | |||||
| await expect(page.locator('.login-visual')).toBeVisible() | |||||
| await expect(page.locator('.login-copy')).toContainText('装备数字车间') | |||||
| await expect(page.locator('.login-copy')).toContainText('统一承载内容制作、虚实训练、训练考核与资源管理。') | |||||
| await expect(page.locator('.login-flow')).toContainText('01身份选择') | |||||
| await expect(page.locator('.login-flow')).toContainText('02账号认证') | |||||
| await expect(page.locator('.login-flow')).toContainText('03进入系统') | |||||
| const pageBackground = await page.locator('.login-page').evaluate((element) => getComputedStyle(element).backgroundImage) | |||||
| expect(pageBackground).toContain('login_bg.jpg') | |||||
| const backgroundResponse = await page.request.get(new URL('/login_bg.jpg', page.url()).toString()) | |||||
| expect(backgroundResponse.status()).toBe(200) | |||||
| expect(backgroundResponse.headers()['content-type']).toContain('image/jpeg') | |||||
| expect((await backgroundResponse.body()).byteLength).toBeGreaterThan(500_000) | |||||
| const logo = page.locator('.login-logo') | |||||
| await expect(logo).toHaveAttribute('src', '/logo-title.png') | |||||
| await expect(logo).toBeVisible() | |||||
| const logoGeometry = await logo.evaluate((element: HTMLImageElement) => ({ | |||||
| complete: element.complete, | |||||
| naturalWidth: element.naturalWidth, | |||||
| naturalHeight: element.naturalHeight, | |||||
| })) | |||||
| expect(logoGeometry).toEqual({ complete: true, naturalWidth: 342, naturalHeight: 65 }) | |||||
| const logoResponse = await page.request.get(new URL('/logo-title.png', page.url()).toString()) | |||||
| expect(logoResponse.status()).toBe(200) | |||||
| expect(logoResponse.headers()['content-type']).toContain('image/png') | |||||
| expect((await logoResponse.body()).byteLength).toBeGreaterThan(30_000) | |||||
| const cardGeometry = await page.locator('.login-card').evaluate((element) => { | |||||
| const box = element.getBoundingClientRect() | |||||
| return { width: box.width, height: box.height } | |||||
| }) | |||||
| expect(Math.abs(cardGeometry.width - 450)).toBeLessThanOrEqual(1) | |||||
| expect(Math.abs(cardGeometry.height - 500)).toBeLessThanOrEqual(1) | |||||
| await expect(page.locator('[data-role-code]')).toHaveCount(4) | |||||
| await expect(page.getByText('其他登录方式', { exact: true })).toBeVisible() | |||||
| const ssoButton = page.getByRole('button', { name: '统一身份认证(SSO)' }) | |||||
| await expect(ssoButton).toBeVisible() | |||||
| const username = page.locator('input[name="username"]') | |||||
| const password = page.locator('input[name="password"]') | |||||
| const remember = page.locator('input[name="rememberMe"]') | |||||
| await username.fill('admin.visual-contract') | |||||
| await password.fill('VisualOnly-NotSubmitted!') | |||||
| await remember.check() | |||||
| const urlBeforeSso = page.url() | |||||
| const pageCountBeforeSso = page.context().pages().length | |||||
| await ssoButton.click() | |||||
| await expect(page.getByText('统一身份认证暂未接入,请先使用账号密码登录', { exact: true })).toBeVisible() | |||||
| expect(page.url()).toBe(urlBeforeSso) | |||||
| expect(page.context().pages()).toHaveLength(pageCountBeforeSso) | |||||
| expect(loginRequestCount).toBe(0) | |||||
| await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true') | |||||
| await expect(username).toHaveValue('admin.visual-contract') | |||||
| await expect(password).toHaveValue('VisualOnly-NotSubmitted!') | |||||
| await expect(remember).toBeChecked() | |||||
| await captureScreenshot(page, testInfo, 'login-prototype-desktop-1920x945') | |||||
| expectNoRuntimeIssues(issues) | |||||
| }) | |||||
| test('390x844 无横向溢出且四身份、组织字段与全部登录控件可滚动操作', async ({ page }, testInfo) => { | |||||
| await page.setViewportSize({ width: 390, height: 844 }) | |||||
| const issues = watchRuntimeIssues(page) | |||||
| await openLoginPage(page) | |||||
| await expect(page.locator('.login-visual')).toBeHidden() | |||||
| await expectNoHorizontalOverflow(page) | |||||
| const roleButtons = page.locator('[data-role-code]') | |||||
| await expect(roleButtons).toHaveCount(4) | |||||
| expect(await roleButtons.evaluateAll((buttons) => buttons.map((button) => ({ | |||||
| code: button.getAttribute('data-role-code'), | |||||
| label: button.textContent?.trim(), | |||||
| })))).toEqual([ | |||||
| { code: 'administrative', label: '行政' }, | |||||
| { code: 'teacher', label: '教员' }, | |||||
| { code: 'student', label: '学员' }, | |||||
| { code: 'admin', label: '管理员' }, | |||||
| ]) | |||||
| await page.locator('[data-role-code="administrative"]').scrollIntoViewIfNeeded() | |||||
| await page.locator('[data-role-code="administrative"]').click() | |||||
| await expect(page.getByTestId('login-department')).toBeVisible() | |||||
| await expect(page.getByTestId('login-department')).toBeEnabled() | |||||
| await expect(page.getByTestId('login-organization')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-identity')).toBeVisible() | |||||
| await expect(page.getByTestId('login-identity')).toBeDisabled() | |||||
| await page.locator('[data-role-code="teacher"]').click() | |||||
| await expect(page.getByTestId('login-organization')).toBeVisible() | |||||
| await expect(page.getByTestId('login-organization')).toBeEnabled() | |||||
| await expect(page.getByTestId('login-department')).toBeVisible() | |||||
| await expect(page.getByTestId('login-department')).toBeDisabled() | |||||
| await expect(page.getByTestId('login-identity')).toBeVisible() | |||||
| await expect(page.getByTestId('login-identity')).toBeDisabled() | |||||
| await page.locator('[data-role-code="student"]').click() | |||||
| await expect(page.getByTestId('login-organization')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-department')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-identity')).toBeVisible() | |||||
| await expect(page.getByTestId('login-identity')).toBeEnabled() | |||||
| await page.locator('[data-role-code="admin"]').click() | |||||
| await expect(page.getByTestId('login-identity')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-organization')).toHaveCount(0) | |||||
| await expect(page.getByTestId('login-department')).toHaveCount(0) | |||||
| const username = page.locator('input[name="username"]') | |||||
| const password = page.locator('input[name="password"]') | |||||
| const passwordToggle = page.locator('.login-password-toggle') | |||||
| const remember = page.locator('input[name="rememberMe"]') | |||||
| const submit = page.getByRole('button', { name: '登录系统' }) | |||||
| const sso = page.getByRole('button', { name: '统一身份认证(SSO)' }) | |||||
| for (const control of [username, password, passwordToggle, remember, submit, sso]) { | |||||
| await control.scrollIntoViewIfNeeded() | |||||
| await expect(control).toBeVisible() | |||||
| } | |||||
| await username.fill('admin.mobile-contract') | |||||
| await password.fill('MobileOnly-NotSubmitted!') | |||||
| await passwordToggle.click() | |||||
| await expect(password).toHaveAttribute('type', 'text') | |||||
| await remember.check() | |||||
| await expect(submit).toBeEnabled() | |||||
| await expectNoHorizontalOverflow(page) | |||||
| const visibleControlBounds = await page.locator('.login-card button:visible, .login-card input:visible').evaluateAll((controls) => controls.map((control) => { | |||||
| const box = control.getBoundingClientRect() | |||||
| return { left: box.left, right: box.right } | |||||
| })) | |||||
| for (const bounds of visibleControlBounds) { | |||||
| expect(bounds.left).toBeGreaterThanOrEqual(0) | |||||
| expect(bounds.right).toBeLessThanOrEqual(391) | |||||
| } | |||||
| await sso.scrollIntoViewIfNeeded() | |||||
| await captureScreenshot(page, testInfo, 'login-prototype-mobile-390x844') | |||||
| expectNoRuntimeIssues(issues) | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,276 @@ | |||||
| import type { Page } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { adminCredentials, confirmMessageBox, loginAsAdmin } from './helpers' | |||||
| const missingProjectId = '9223372036854775807' | |||||
| const contentPages = [ | |||||
| { path: '/content/models', childPath: `/content/models/${missingProjectId}/edit`, label: '模型制作' }, | |||||
| { path: '/content/scenes', childPath: `/content/scenes/${missingProjectId}/edit`, label: '场景制作' }, | |||||
| { path: '/content/training-projects', childPath: `/content/training-projects/${missingProjectId}/edit`, label: '训练编排' }, | |||||
| { path: '/content/ofd', childPath: `/content/ofd/${missingProjectId}/preview`, label: '作业指导' }, | |||||
| ] as const | |||||
| const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |||||
| const menuRow = (page: Page, name: string) => page | |||||
| .locator('.menu-table-panel .el-table__body-wrapper .el-table__row') | |||||
| .filter({ | |||||
| has: page.locator('.menu-name-cell b').filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) }), | |||||
| }) | |||||
| .first() | |||||
| const waitForMenuManagement = async (page: Page) => { | |||||
| await expect(page.locator('.menus-page')).toBeVisible() | |||||
| await expect(page.locator('.menu-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 15_000 }) | |||||
| await expect(page.locator('.menu-table-panel .el-table__body-wrapper .el-table__row').first()).toBeVisible({ timeout: 15_000 }) | |||||
| } | |||||
| const expectActiveContentMenu = async (page: Page, label: string, href: string) => { | |||||
| await expect(page.locator('.header-navigation .header-nav-trigger.active')).toContainText('内容制作') | |||||
| const activeItem = page.locator('.secondary-navigation a.active') | |||||
| await expect(activeItem).toContainText(label) | |||||
| await expect(activeItem).toHaveAttribute('href', href) | |||||
| } | |||||
| const waitForContentList = async (page: Page) => { | |||||
| await expect(page.locator('.content-projects-page .content-table-panel')).toBeVisible() | |||||
| await expect(page.locator('.content-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 15_000 }) | |||||
| } | |||||
| const positionText = (row: ReturnType<typeof menuRow>) => row | |||||
| .locator('.cell') | |||||
| .filter({ hasText: /^\d+\s*\/\s*\d+$/ }) | |||||
| .first() | |||||
| test('菜单草稿操作不落库且新版内容导航与子路由活动态正确', async ({ page }, testInfo) => { | |||||
| await loginAsAdmin(page) | |||||
| await test.step('管理员进入菜单管理并验证搜索与重置', async () => { | |||||
| await page.goto('/system/menus') | |||||
| await expect(page).toHaveURL(/\/system\/menus(?:\?|$)/) | |||||
| // 页头卡已去掉,页面身份改由页面标签栏承担 | |||||
| await expect(page.getByRole('tab', { name: '菜单管理', exact: true })).toBeVisible() | |||||
| await waitForMenuManagement(page) | |||||
| await expect(page.locator('.secondary-navigation a.active')).toContainText('菜单管理') | |||||
| const toolbar = page.locator('.menu-toolbar') | |||||
| const query = toolbar.getByPlaceholder('请输入菜单名称、权限码或说明') | |||||
| await query.fill('模型制作') | |||||
| await toolbar.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| await expect(menuRow(page, '模型制作')).toBeVisible() | |||||
| await expect(page.locator('.sorting-note')).toBeVisible() | |||||
| await captureScreenshot(page, testInfo, 'menu-search-model') | |||||
| await toolbar.getByRole('button', { name: '重置', exact: true }).click() | |||||
| await expect(query).toHaveValue('') | |||||
| await expect(page.locator('.sorting-note')).toHaveCount(0) | |||||
| await expect(menuRow(page, '场景制作')).toBeVisible() | |||||
| }) | |||||
| const menuMutationRequests: string[] = [] | |||||
| page.on('request', (request) => { | |||||
| if (/\/menus(?:\/|\?|$)/.test(request.url()) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) { | |||||
| menuMutationRequests.push(`${request.method()} ${request.url()}`) | |||||
| } | |||||
| }) | |||||
| await test.step('三种菜单布局预览均可切换', async () => { | |||||
| await page.getByRole('button', { name: '布局预览', exact: true }).click() | |||||
| const drawer = page.locator('.menu-preview-drawer:visible, .el-drawer:visible').last() | |||||
| const preview = drawer.locator('.menu-layout-preview') | |||||
| await expect(drawer).toBeVisible() | |||||
| const modes = [ | |||||
| { label: '顶部一级 + 左侧二级', value: 'top-side', screenshot: 'menu-preview-top-side' }, | |||||
| { label: '左侧分组', value: 'side', screenshot: 'menu-preview-side' }, | |||||
| { label: '顶部下拉', value: 'top', screenshot: 'menu-preview-top' }, | |||||
| ] as const | |||||
| for (const mode of modes) { | |||||
| await drawer.locator('.el-radio-button').filter({ hasText: mode.label }).click() | |||||
| await expect(preview).toHaveAttribute('data-mode', mode.value) | |||||
| if (mode.value === 'top-side') { | |||||
| await expect(preview.locator(':scope > header')).toBeVisible() | |||||
| await expect(preview.locator(':scope > aside')).toBeVisible() | |||||
| await expect(preview.locator('.preview-dropdown')).toHaveCount(0) | |||||
| } else if (mode.value === 'side') { | |||||
| await expect(preview.locator(':scope > header')).toHaveCount(0) | |||||
| await expect(preview.locator(':scope > aside')).toBeVisible() | |||||
| await expect(preview.locator('.preview-tree-group').first()).toBeVisible() | |||||
| } else { | |||||
| await expect(preview.locator(':scope > header')).toBeVisible() | |||||
| await expect(preview.locator(':scope > aside')).toHaveCount(0) | |||||
| await expect(preview.locator('.preview-dropdown')).toBeVisible() | |||||
| } | |||||
| await captureScreenshot(page, testInfo, mode.screenshot) | |||||
| } | |||||
| await page.keyboard.press('Escape') | |||||
| await expect(drawer).toBeHidden() | |||||
| }) | |||||
| await test.step('编辑显示名称只形成草稿并可完整撤销', async () => { | |||||
| const originalName = '模型制作' | |||||
| const temporaryName = '模型制作临时草稿' | |||||
| const row = menuRow(page, originalName) | |||||
| await row.getByRole('button', { name: '编辑', exact: true }).click() | |||||
| const drawer = page.locator('.el-drawer:visible').last() | |||||
| await expect(drawer).toContainText('编辑菜单显示信息') | |||||
| await drawer.getByPlaceholder('请输入 2–16 个字符的菜单名称').fill(temporaryName) | |||||
| await drawer.getByRole('button', { name: '应用到草稿', exact: true }).click() | |||||
| await expect(drawer).toBeHidden() | |||||
| await expect(menuRow(page, temporaryName)).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeEnabled() | |||||
| await captureScreenshot(page, testInfo, 'menu-edit-draft') | |||||
| await page.getByRole('button', { name: '撤销草稿', exact: true }).click() | |||||
| await confirmMessageBox(page, '撤销修改') | |||||
| await expect(menuRow(page, originalName)).toBeVisible() | |||||
| await expect(menuRow(page, temporaryName)).toHaveCount(0) | |||||
| await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeDisabled() | |||||
| }) | |||||
| await test.step('同级排序只形成草稿并可完整撤销', async () => { | |||||
| const row = menuRow(page, '模型制作') | |||||
| const originalPosition = (await positionText(row).innerText()).trim() | |||||
| const match = originalPosition.match(/^(\d+)\s*\/\s*(\d+)$/) | |||||
| expect(match, `无法识别菜单当前位置:${originalPosition}`).not.toBeNull() | |||||
| const current = Number(match?.[1]) | |||||
| const total = Number(match?.[2]) | |||||
| expect(total).toBeGreaterThan(1) | |||||
| const moveLabel = current < total ? '下移' : '上移' | |||||
| const expectedPosition = `${current < total ? current + 1 : current - 1} / ${total}` | |||||
| await row.getByRole('button', { name: moveLabel, exact: true }).click() | |||||
| await expect(positionText(menuRow(page, '模型制作'))).toHaveText(expectedPosition) | |||||
| await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeEnabled() | |||||
| await captureScreenshot(page, testInfo, 'menu-reorder-draft') | |||||
| await page.getByRole('button', { name: '撤销草稿', exact: true }).click() | |||||
| await confirmMessageBox(page, '撤销修改') | |||||
| await expect(positionText(menuRow(page, '模型制作'))).toHaveText(originalPosition) | |||||
| await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeDisabled() | |||||
| }) | |||||
| await test.step('四类内容新名称、训练模式与列表活动态正确', async () => { | |||||
| for (const item of contentPages) { | |||||
| await page.goto(item.path) | |||||
| await expect(page).toHaveURL(new RegExp(`${item.path.replaceAll('/', '\\/')}(?:\\?|$)`)) | |||||
| // 内容制作页面已去掉 SystemPageHeader,页面身份改由页面标签栏承担 | |||||
| await expect(page.getByRole('tab', { name: item.label, exact: true })).toBeVisible() | |||||
| await waitForContentList(page) | |||||
| await expectActiveContentMenu(page, item.label, item.path) | |||||
| if (item.path === '/content/training-projects') { | |||||
| const modeTabs = page.locator('.training-mode-tabs button') | |||||
| await expect(modeTabs).toHaveCount(3) | |||||
| await expect(modeTabs.filter({ hasText: '虚拟仿真' })).toBeVisible() | |||||
| await expect(modeTabs.filter({ hasText: '实装实训' })).toBeVisible() | |||||
| await expect(modeTabs.filter({ hasText: '对抗训练' })).toBeVisible() | |||||
| await expect(modeTabs.filter({ hasText: '虚拟仿真' })).toHaveAttribute('aria-pressed', 'true') | |||||
| await captureScreenshot(page, testInfo, 'content-training-three-modes') | |||||
| } | |||||
| if (item.path === '/content/ofd') { | |||||
| await expect(page.locator('.content-table-panel')).toBeVisible() | |||||
| await captureScreenshot(page, testInfo, 'content-guide-list-or-empty') | |||||
| } | |||||
| } | |||||
| }) | |||||
| await test.step('内容编辑、预览子路由保持对应菜单活动态', async () => { | |||||
| for (const item of contentPages) { | |||||
| await page.goto(item.childPath) | |||||
| await expect(page).toHaveURL(new RegExp(`${item.childPath.replaceAll('/', '\\/')}(?:\\?|$)`)) | |||||
| await expect(page.locator('.content-editor-page, .guide-workflow-page')).toBeVisible() | |||||
| await expectActiveContentMenu(page, item.label, item.path) | |||||
| } | |||||
| await captureScreenshot(page, testInfo, 'content-child-route-active') | |||||
| }) | |||||
| expect(menuMutationRequests, '草稿编辑、排序、预览与撤销不应调用菜单写接口').toEqual([]) | |||||
| }) | |||||
| interface ApiMenuNode { | |||||
| id: string | |||||
| name: string | |||||
| description: string | |||||
| dataVersion: number | |||||
| children: ApiMenuNode[] | |||||
| } | |||||
| const configurationPayload = (desired: ApiMenuNode[], versionSource: ApiMenuNode[]) => { | |||||
| const versions = new Map(versionSource.flatMap((group) => [group, ...group.children]).map((node) => [String(node.id), node.dataVersion])) | |||||
| return { | |||||
| groups: desired.map((group) => ({ | |||||
| id: String(group.id), | |||||
| name: group.name, | |||||
| description: group.description, | |||||
| version: versions.get(String(group.id)), | |||||
| items: group.children.map((item) => ({ | |||||
| id: String(item.id), | |||||
| name: item.name, | |||||
| description: item.description, | |||||
| version: versions.get(String(item.id)), | |||||
| })), | |||||
| })), | |||||
| } | |||||
| } | |||||
| test('菜单配置可原子保存并恢复原始快照', async ({ request }) => { | |||||
| const credentials = adminCredentials() | |||||
| const loginResponse = await request.post('/api/auth/v1/auth/login', { | |||||
| data: { username: credentials.username, password: credentials.password, roleCode: 'admin', rememberMe: false }, | |||||
| }) | |||||
| expect(loginResponse.ok()).toBeTruthy() | |||||
| const loginBody = await loginResponse.json() as { data?: { accessToken?: string } } | |||||
| const accessToken = loginBody.data?.accessToken | |||||
| expect(accessToken).toBeTruthy() | |||||
| const headers = { Authorization: `Bearer ${accessToken}` } | |||||
| const readTree = async () => { | |||||
| const response = await request.get('/api/auth/v1/menus/tree', { headers }) | |||||
| expect(response.ok()).toBeTruthy() | |||||
| const body = await response.json() as { data?: ApiMenuNode[] } | |||||
| return body.data ?? [] | |||||
| } | |||||
| try { | |||||
| const baseline = await readTree() | |||||
| expect(baseline).toHaveLength(5) | |||||
| const draft = structuredClone(baseline) | |||||
| const contentGroup = draft.find((group) => group.children.some((item) => item.name === '模型制作')) | |||||
| const modelItem = contentGroup?.children.find((item) => item.name === '模型制作') | |||||
| expect(modelItem).toBeTruthy() | |||||
| modelItem!.name = '模型制作验收' | |||||
| let changed = false | |||||
| try { | |||||
| const saveResponse = await request.put('/api/auth/v1/menus/configuration', { | |||||
| headers, | |||||
| data: configurationPayload(draft, baseline), | |||||
| }) | |||||
| expect(saveResponse.ok()).toBeTruthy() | |||||
| changed = true | |||||
| const saved = await readTree() | |||||
| expect(saved.flatMap((group) => group.children).some((item) => item.name === '模型制作验收')).toBeTruthy() | |||||
| } finally { | |||||
| if (changed) { | |||||
| const current = await readTree() | |||||
| const restoreResponse = await request.put('/api/auth/v1/menus/configuration', { | |||||
| headers, | |||||
| data: configurationPayload(baseline, current), | |||||
| }) | |||||
| expect(restoreResponse.ok()).toBeTruthy() | |||||
| const restored = await readTree() | |||||
| expect(restored.flatMap((group) => group.children).some((item) => item.name === '模型制作')).toBeTruthy() | |||||
| expect(restored.flatMap((group) => group.children).some((item) => item.name === '模型制作验收')).toBeFalsy() | |||||
| } | |||||
| } | |||||
| } finally { | |||||
| await request.post('/api/auth/v1/auth/logout', { headers }) | |||||
| } | |||||
| }) | |||||
| @@ -0,0 +1,204 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const profile = { | |||||
| user: { | |||||
| id: '1', | |||||
| username: 'admin', | |||||
| displayName: '系统管理员', | |||||
| departmentId: '10', | |||||
| departmentName: '信息中心', | |||||
| mustChangePassword: false, | |||||
| version: 3, | |||||
| }, | |||||
| activeRoleId: '100', | |||||
| activeRole: { id: '100', code: 'admin', name: '管理员' }, | |||||
| roles: [ | |||||
| { | |||||
| id: '100', | |||||
| code: 'admin', | |||||
| name: '管理员', | |||||
| shortName: '管', | |||||
| status: 1, | |||||
| builtIn: 1, | |||||
| isSuperAdmin: 1, | |||||
| dataScopeCode: 'ALL', | |||||
| }, | |||||
| ], | |||||
| permissions: ['dashboard.view'], | |||||
| authorizationMode: 'SINGLE_ACTIVE', | |||||
| loginTime: 1786387200, | |||||
| } as const | |||||
| const envelope = (data: unknown, requestId: string) => JSON.stringify({ | |||||
| code: 200, | |||||
| message: '成功', | |||||
| data, | |||||
| timestamp: '2026-08-11T10:00:00+08:00', | |||||
| requestId, | |||||
| }) | |||||
| const fulfillJson = (route: Route, data: unknown, requestId: string, status = 200) => route.fulfill({ | |||||
| status, | |||||
| contentType: 'application/json', | |||||
| body: envelope(data, requestId), | |||||
| }) | |||||
| const prepareAuthenticatedProfile = async (page: Page) => { | |||||
| await page.addInitScript(() => { | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', 'contract-access-token') | |||||
| }) | |||||
| await page.route('**/api/auth/v1/auth/me', (route) => fulfillJson(route, profile, 'ute2e-profile-me')) | |||||
| await page.route('**/api/auth/v1/menus/navigation', (route) => fulfillJson(route, [], 'ute2e-profile-menu')) | |||||
| } | |||||
| test.describe('平台账号与个人中心', () => { | |||||
| test('页头精简且账号菜单仅保留个人中心和退出登录', async ({ page }, testInfo) => { | |||||
| await prepareAuthenticatedProfile(page) | |||||
| await page.goto('/profile') | |||||
| const header = page.locator('.platform-header') | |||||
| await expect(header).toBeVisible() | |||||
| expect((await header.boundingBox())?.height).toBeLessThanOrEqual(62) | |||||
| await expect(header).not.toContainText('Maintenance Digital Workshop') | |||||
| await page.getByRole('button', { name: '账号菜单:系统管理员' }).click() | |||||
| const dropdown = page.locator('.user-dropdown:visible') | |||||
| await expect(dropdown).toBeVisible() | |||||
| await expect(dropdown.locator('.el-dropdown-menu__item')).toHaveCount(2) | |||||
| await expect(dropdown.getByText('个人中心', { exact: true })).toBeVisible() | |||||
| await expect(dropdown.getByText('退出登录', { exact: true })).toBeVisible() | |||||
| await expect(dropdown).not.toContainText('修改密码') | |||||
| await expect(dropdown).not.toContainText('角色切换') | |||||
| await page.keyboard.press('Escape') | |||||
| await captureScreenshot(page, testInfo, 'profile-header-account-menu') | |||||
| }) | |||||
| test('个人中心展示真实资料并通过接口修改密码', async ({ page }, testInfo) => { | |||||
| await prepareAuthenticatedProfile(page) | |||||
| let passwordRequest: Record<string, unknown> | null = null | |||||
| await page.route('**/api/auth/v1/auth/password', async (route) => { | |||||
| passwordRequest = route.request().postDataJSON() as Record<string, unknown> | |||||
| await fulfillJson(route, { reauthenticationRequired: false }, 'ute2e-profile-password') | |||||
| }) | |||||
| await page.goto('/profile') | |||||
| await expect(page.getByRole('heading', { name: '个人中心' })).toBeVisible() | |||||
| await expect(page.getByText('系统管理员 · admin', { exact: true })).toBeVisible() | |||||
| const descriptions = page.locator('.profile-descriptions') | |||||
| await expect(descriptions).toHaveClass(/el-descriptions/) | |||||
| await expect(descriptions.locator('.el-descriptions__table')).toBeVisible() | |||||
| await expect(descriptions).toContainText('信息中心') | |||||
| await expect(descriptions).toContainText('管理员') | |||||
| const columnWidths = await page.locator('.profile-grid > .system-panel').evaluateAll((panels) => panels.map((panel) => panel.getBoundingClientRect().width)) | |||||
| expect(columnWidths).toHaveLength(2) | |||||
| expect(Math.abs(columnWidths[0]! - columnWidths[1]!)).toBeLessThanOrEqual(1) | |||||
| const currentPassword = page.locator('input[name="currentPassword"]') | |||||
| const newPassword = page.locator('input[name="newPassword"]') | |||||
| const confirmPassword = page.locator('input[name="confirmPassword"]') | |||||
| await expect(currentPassword).toHaveAttribute('placeholder', '请输入当前密码') | |||||
| await expect(newPassword).toHaveAttribute('placeholder', '请输入符合全部规则的新密码') | |||||
| await expect(confirmPassword).toHaveAttribute('placeholder', '请再次输入新密码') | |||||
| await currentPassword.fill('Current-Password-1') | |||||
| await expect(page.locator('.profile-password .password-requirements .is-unmet')).toHaveCount(5) | |||||
| await newPassword.fill('Updated-Password-2') | |||||
| await expect(page.locator('.profile-password .password-requirements .is-met')).toHaveCount(5) | |||||
| await confirmPassword.fill('Updated-Password-2') | |||||
| const passwordToggles = page.locator('.profile-password .el-input__password') | |||||
| await expect(passwordToggles).toHaveCount(3) | |||||
| await expect(passwordToggles.locator('svg')).toHaveCount(3) | |||||
| await passwordToggles.first().click() | |||||
| await expect(currentPassword).toHaveAttribute('type', 'text') | |||||
| await passwordToggles.first().click() | |||||
| await expect(currentPassword).toHaveAttribute('type', 'password') | |||||
| await page.getByRole('button', { name: '修改密码' }).click() | |||||
| await expect.poll(() => passwordRequest).toEqual({ | |||||
| oldPassword: 'Current-Password-1', | |||||
| newPassword: 'Updated-Password-2', | |||||
| }) | |||||
| await expect(page.getByText('密码修改成功', { exact: true })).toBeVisible() | |||||
| await expect(currentPassword).toHaveValue('') | |||||
| await expect(newPassword).toHaveValue('') | |||||
| await expect(confirmPassword).toHaveValue('') | |||||
| await captureScreenshot(page, testInfo, 'profile-information-and-password') | |||||
| await page.setViewportSize({ width: 375, height: 812 }) | |||||
| await expect(descriptions).toBeVisible() | |||||
| const hasHorizontalOverflow = await page.locator('.profile-page').evaluate((element) => element.scrollWidth > element.clientWidth + 1) | |||||
| expect(hasHorizontalOverflow).toBe(false) | |||||
| await captureScreenshot(page, testInfo, 'profile-mobile-descriptions') | |||||
| }) | |||||
| test('首次强制改密复用相同的五项密码规则', async ({ page }, testInfo) => { | |||||
| await page.addInitScript(() => { | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', 'contract-access-token') | |||||
| }) | |||||
| let mustChangePassword = true | |||||
| let passwordChanged = false | |||||
| await page.route('**/api/auth/v1/auth/me', (route) => { | |||||
| if (passwordChanged) { | |||||
| return route.fulfill({ | |||||
| status: 403, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ | |||||
| code: 40302, | |||||
| message: '首次登录必须先修改密码', | |||||
| data: null, | |||||
| timestamp: '2026-08-11T10:00:00+08:00', | |||||
| requestId: 'ute2e-forced-password-stale-me', | |||||
| }), | |||||
| }) | |||||
| } | |||||
| return fulfillJson(route, { | |||||
| ...profile, | |||||
| user: { ...profile.user, mustChangePassword }, | |||||
| mustChangePassword, | |||||
| }, 'ute2e-forced-password-me') | |||||
| }) | |||||
| await page.route('**/api/auth/v1/menus/navigation', (route) => fulfillJson(route, [], 'ute2e-forced-password-menu')) | |||||
| let passwordRequest: Record<string, unknown> | null = null | |||||
| await page.route('**/api/auth/v1/auth/initial-password', async (route) => { | |||||
| passwordRequest = route.request().postDataJSON() as Record<string, unknown> | |||||
| mustChangePassword = false | |||||
| passwordChanged = true | |||||
| await fulfillJson(route, { | |||||
| accessToken: 'fresh-access-token', | |||||
| refreshToken: 'fresh-refresh-token', | |||||
| tokenType: 'Bearer', | |||||
| accessExpiresTime: 1786177800, | |||||
| refreshExpiresTime: 1786780800, | |||||
| user: { | |||||
| ...profile, | |||||
| user: { ...profile.user, mustChangePassword: false }, | |||||
| mustChangePassword: false, | |||||
| }, | |||||
| }, 'ute2e-forced-password-change') | |||||
| }) | |||||
| await page.goto('/change-password') | |||||
| await expect(page.getByRole('heading', { name: '首次登录修改密码' })).toBeVisible() | |||||
| const inputs = page.locator('.password-card input') | |||||
| await expect(inputs).toHaveCount(2) | |||||
| const newPassword = inputs.nth(0) | |||||
| const confirmation = inputs.nth(1) | |||||
| const submit = page.getByRole('button', { name: '确认修改并登录' }) | |||||
| await expect(page.getByLabel('当前密码')).toHaveCount(0) | |||||
| await expect(page.locator('.password-requirements .is-unmet')).toHaveCount(5) | |||||
| await newPassword.fill('MissingCategories') | |||||
| await confirmation.fill('MissingCategories') | |||||
| await expect(submit).toBeDisabled() | |||||
| await newPassword.fill('Forced#Change8') | |||||
| await confirmation.fill('Forced#Change8') | |||||
| await expect(page.locator('.password-requirements .is-met')).toHaveCount(5) | |||||
| await expect(submit).toBeEnabled() | |||||
| await captureScreenshot(page, testInfo, 'forced-password-requirements') | |||||
| await submit.click() | |||||
| await expect.poll(() => passwordRequest).toEqual({ | |||||
| newPassword: 'Forced#Change8', | |||||
| }) | |||||
| await expect(page).toHaveURL(/\/dashboard/) | |||||
| await expect(page.getByText('首次登录必须先修改密码')).toHaveCount(0) | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,114 @@ | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { loginAsAdmin } from './helpers' | |||||
| const navigationModes = [ | |||||
| { label: '顶部一级 + 左侧二级', value: 'top-side' }, | |||||
| { label: '左侧分组菜单', value: 'side' }, | |||||
| { label: '顶部下拉菜单', value: 'top' }, | |||||
| ] as const | |||||
| const waitForDashboard = async (page: import('@playwright/test').Page) => { | |||||
| await expect(page.locator('.platform-shell')).toBeVisible() | |||||
| await expect(page.getByRole('heading', { name: '前后端联调状态' })).toBeVisible() | |||||
| await expect(page.getByText('2/2', { exact: true })).toBeVisible({ timeout: 15_000 }) | |||||
| await expect(page.locator('.dashboard-metrics article').first().locator('b')).not.toHaveText('0', { timeout: 15_000 }) | |||||
| } | |||||
| const contrastRatio = (foreground: string, background: string) => { | |||||
| const channels = (value: string) => (value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number) | |||||
| const luminance = (value: string) => channels(value).reduce((sum, channel, index) => { | |||||
| const normalized = channel / 255 | |||||
| const linear = normalized <= .03928 ? normalized / 12.92 : ((normalized + .055) / 1.055) ** 2.4 | |||||
| return sum + linear * [.2126, .7152, .0722][index]! | |||||
| }, 0) | |||||
| const [lighter, darker] = [luminance(foreground), luminance(background)].sort((left, right) => right - left) | |||||
| return (lighter! + .05) / (darker! + .05) | |||||
| } | |||||
| test('三种导航模式与三种主题可以持久化', async ({ page }, testInfo) => { | |||||
| await page.emulateMedia({ colorScheme: 'dark' }) | |||||
| await loginAsAdmin(page) | |||||
| await expect(page.locator('.platform-shell')).toHaveAttribute('data-navigation-mode', 'top-side') | |||||
| await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') | |||||
| await expect(page.locator('.platform-header')).toHaveCSS('background-color', 'rgb(255, 255, 255)') | |||||
| expect(await page.locator('html').evaluate((element) => getComputedStyle(element).getPropertyValue('--primary').trim())).toBe('#0b7a70') | |||||
| for (const mode of navigationModes) { | |||||
| await test.step(`导航模式:${mode.label}`, async () => { | |||||
| await page.getByRole('button', { name: '界面与导航设置' }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await drawer.getByRole('button', { name: new RegExp(mode.label.replace('+', '\\+')) }).click() | |||||
| await expect(page.locator('.platform-shell')).toHaveAttribute('data-navigation-mode', mode.value) | |||||
| await page.keyboard.press('Escape') | |||||
| await page.reload() | |||||
| await expect(page.locator('.platform-shell')).toHaveAttribute('data-navigation-mode', mode.value) | |||||
| await waitForDashboard(page) | |||||
| await captureScreenshot(page, testInfo, `navigation-${mode.value}`) | |||||
| }) | |||||
| } | |||||
| await test.step('桌面侧栏收起、刷新持久化与展开恢复', async () => { | |||||
| await page.getByRole('button', { name: '界面与导航设置' }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await drawer.getByRole('button', { name: /顶部一级 \+ 左侧二级/ }).click() | |||||
| await page.keyboard.press('Escape') | |||||
| await expect(drawer).toBeHidden() | |||||
| const shell = page.locator('.platform-shell') | |||||
| await expect(shell).toHaveAttribute('data-navigation-mode', 'top-side') | |||||
| const collapseButton = page.getByRole('button', { name: '收起左侧导航' }) | |||||
| await expect(collapseButton).toBeVisible() | |||||
| await collapseButton.click() | |||||
| await expect(shell).toHaveClass(/sidebar-collapsed/) | |||||
| await expect(page.getByRole('button', { name: '展开左侧导航' })).toBeVisible() | |||||
| await captureScreenshot(page, testInfo, 'sidebar-collapsed') | |||||
| await page.reload() | |||||
| await waitForDashboard(page) | |||||
| await expect(shell).toHaveClass(/sidebar-collapsed/) | |||||
| const expandButton = page.getByRole('button', { name: '展开左侧导航' }) | |||||
| await expect(expandButton).toBeVisible() | |||||
| await expandButton.click() | |||||
| await expect(shell).not.toHaveClass(/sidebar-collapsed/) | |||||
| await expect(page.getByRole('button', { name: '收起左侧导航' })).toBeVisible() | |||||
| await captureScreenshot(page, testInfo, 'sidebar-expanded') | |||||
| await page.reload() | |||||
| await waitForDashboard(page) | |||||
| await expect(shell).not.toHaveClass(/sidebar-collapsed/) | |||||
| }) | |||||
| const themes = [ | |||||
| { label: '明亮', value: 'light', expected: 'light' }, | |||||
| { label: '暗黑', value: 'dark', expected: 'dark' }, | |||||
| { label: '跟随系统', value: 'system', expected: 'dark' }, | |||||
| ] as const | |||||
| for (const theme of themes) { | |||||
| await test.step(`主题模式:${theme.label}`, async () => { | |||||
| await page.getByRole('button', { name: '界面与导航设置' }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await drawer.getByRole('button', { name: new RegExp(theme.label) }).click() | |||||
| await expect(page.locator('html')).toHaveAttribute('data-theme', theme.expected) | |||||
| await page.keyboard.press('Escape') | |||||
| await page.reload() | |||||
| await expect(page.locator('html')).toHaveAttribute('data-theme', theme.expected) | |||||
| await waitForDashboard(page) | |||||
| if (theme.expected === 'dark') { | |||||
| const colors = await page.locator('.dashboard-metrics article').first().evaluate((element) => ({ | |||||
| foreground: getComputedStyle(element).color, | |||||
| background: getComputedStyle(element).backgroundColor, | |||||
| })) | |||||
| expect(contrastRatio(colors.foreground, colors.background)).toBeGreaterThanOrEqual(4.5) | |||||
| expect(await page.locator('html').evaluate((element) => getComputedStyle(element).getPropertyValue('--primary').trim())).toBe('#55d0bd') | |||||
| } | |||||
| await captureScreenshot(page, testInfo, `theme-${theme.value}`) | |||||
| }) | |||||
| } | |||||
| // 保持默认值,避免 headed 模式下人工继续检查时继承最后一次设置。 | |||||
| await page.getByRole('button', { name: '界面与导航设置' }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await drawer.getByRole('button', { name: /明亮/ }).click() | |||||
| await drawer.getByRole('button', { name: /顶部一级 \+ 左侧二级/ }).click() | |||||
| }) | |||||
| @@ -0,0 +1,119 @@ | |||||
| import type { Locator, Page, Route } from '@playwright/test' | |||||
| import { expect, test } from './fixtures' | |||||
| type RoleRecord = { | |||||
| id: string | |||||
| code: string | |||||
| name: string | |||||
| shortName: string | |||||
| description: string | |||||
| enabled: boolean | |||||
| builtIn: boolean | |||||
| isSuperAdmin: boolean | |||||
| dataScopeCode: string | |||||
| departmentScopeIds: string[] | |||||
| linkedUserCount: number | |||||
| permissionCount: number | |||||
| sortOrder: number | |||||
| version: number | |||||
| } | |||||
| const json = (route: Route, data: unknown) => route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ code: 0, message: '成功', data, timestamp: Date.now(), requestId: 'role-form-regression' }), | |||||
| }) | |||||
| const fillFormItem = (dialog: Locator, label: string, value: string) => dialog | |||||
| .locator('.el-form-item') | |||||
| .filter({ hasText: label }) | |||||
| .first() | |||||
| .locator('input, textarea') | |||||
| .first() | |||||
| .fill(value) | |||||
| async function mockRoleApi(page: Page, submitted: Array<Record<string, unknown>>) { | |||||
| const roles: RoleRecord[] = [{ | |||||
| id: '1', code: 'admin', name: '管理员', shortName: '管', description: '系统管理员', | |||||
| enabled: true, builtIn: true, isSuperAdmin: true, dataScopeCode: 'ALL', departmentScopeIds: [], | |||||
| linkedUserCount: 1, permissionCount: 30, sortOrder: 10, version: 1, | |||||
| }] | |||||
| await page.route(/\/api\/(?:auth|tran)\//, async (route) => { | |||||
| const url = new URL(route.request().url()) | |||||
| const path = url.pathname | |||||
| const method = route.request().method() | |||||
| if (path.endsWith('/auth/me')) return json(route, { | |||||
| userId: '1', username: 'admin', displayName: '系统管理员', departmentId: '100', departmentName: '数字车间', | |||||
| activeRoleId: '1', roles, authorizationMode: 'SINGLE_ACTIVE', mustChangePassword: false, | |||||
| permissions: ['system.roles', 'system.roles.create', 'system.roles.update', 'system.roles.delete'], | |||||
| }) | |||||
| if (path.endsWith('/roles/all')) return json(route, roles) | |||||
| if (path.endsWith('/roles/stats')) return json(route, { | |||||
| total: roles.length, | |||||
| enabled: roles.filter((role) => role.enabled).length, | |||||
| builtIn: roles.filter((role) => role.builtIn).length, | |||||
| assignedUsers: roles.reduce((total, role) => total + role.linkedUserCount, 0), | |||||
| }) | |||||
| if (path.endsWith('/departments/tree')) return json(route, []) | |||||
| if (path.endsWith('/roles') && method === 'POST') { | |||||
| const body = route.request().postDataJSON() as Record<string, unknown> | |||||
| submitted.push(body) | |||||
| const created: RoleRecord = { | |||||
| id: '2', code: String(body.code), name: String(body.name), shortName: String(body.shortName ?? ''), | |||||
| description: String(body.description ?? ''), enabled: true, builtIn: false, isSuperAdmin: false, | |||||
| dataScopeCode: String(body.dataScope), departmentScopeIds: [], linkedUserCount: 0, permissionCount: 0, | |||||
| sortOrder: Number(body.sortOrder), version: 1, | |||||
| } | |||||
| roles.push(created) | |||||
| return json(route, created) | |||||
| } | |||||
| if (/\/roles\/2$/.test(path) && method === 'PUT') { | |||||
| const body = route.request().postDataJSON() as Record<string, unknown> | |||||
| submitted.push(body) | |||||
| Object.assign(roles[1]!, { | |||||
| name: String(body.name), shortName: String(body.shortName ?? ''), description: String(body.description ?? ''), | |||||
| dataScopeCode: String(body.dataScope), version: roles[1]!.version + 1, | |||||
| }) | |||||
| return json(route, roles[1]) | |||||
| } | |||||
| if (path.endsWith('/system-config/public')) return json(route, { systemName: '数字车间' }) | |||||
| if (path.endsWith('/auth/ping')) return json(route, { service: 'ut-auth', status: 'UP' }) | |||||
| if (path.endsWith('/tran/ping')) return json(route, { service: 'ut-tran', status: 'UP' }) | |||||
| return json(route, {}) | |||||
| }) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('unreal-tran:web:access-token:v1', 'role-form-regression-token') | |||||
| }) | |||||
| } | |||||
| test('角色表单保留空简称、提供清晰提示,并在校验失败时提示用户', async ({ page }) => { | |||||
| const submitted: Array<Record<string, unknown>> = [] | |||||
| await mockRoleApi(page, submitted) | |||||
| await page.goto('/system/roles') | |||||
| await page.getByRole('button', { name: '新增角色', exact: true }).click() | |||||
| let dialog = page.locator('.el-dialog:visible') | |||||
| const shortInput = dialog.getByPlaceholder('请输入界面简称,如:审核') | |||||
| await expect(shortInput).toBeVisible() | |||||
| await fillFormItem(dialog, '角色名称', '测试角色') | |||||
| await fillFormItem(dialog, '角色编码', `a${'1'.repeat(48)}`) | |||||
| await dialog.getByRole('button', { name: '创建角色' }).click() | |||||
| await expect(page.locator('.el-message').filter({ hasText: '请检查表单中的错误项' })).toBeVisible() | |||||
| expect(submitted).toHaveLength(0) | |||||
| await fillFormItem(dialog, '角色编码', 'test_role') | |||||
| await dialog.getByRole('button', { name: '创建角色' }).click() | |||||
| await expect(dialog).toBeHidden() | |||||
| expect(submitted[0]).toMatchObject({ name: '测试角色', code: 'test_role', shortName: '' }) | |||||
| const createdRow = page.locator('.roles-table .el-table__row').filter({ hasText: '测试角色' }) | |||||
| await createdRow.getByRole('button', { name: '编辑' }).click() | |||||
| dialog = page.locator('.el-dialog:visible') | |||||
| await expect(dialog.getByPlaceholder('请输入界面简称,如:审核')).toHaveValue('') | |||||
| await dialog.getByRole('button', { name: '保存修改' }).click() | |||||
| await expect(dialog).toBeHidden() | |||||
| expect(submitted[1]).toMatchObject({ name: '测试角色', code: 'test_role', shortName: '' }) | |||||
| }) | |||||
| @@ -0,0 +1,354 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const DEFAULT_SYSTEM_NAME = '某类装备维修实训数字车间' | |||||
| const PNG_BYTES = Buffer.from( | |||||
| 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', | |||||
| 'base64', | |||||
| ) | |||||
| type BrandAsset = 'logo' | 'favicon' | |||||
| interface ConfigurationState { | |||||
| systemName: string | |||||
| systemNameConfigured: boolean | |||||
| systemNameDataVersion: number | |||||
| logoConfigured: boolean | |||||
| logoUrl: string | null | |||||
| logoMediaType: string | null | |||||
| logoOriginalName: string | null | |||||
| logoSize: number | null | |||||
| logoSha256: string | null | |||||
| logoDataVersion: number | |||||
| faviconConfigured: boolean | |||||
| faviconUrl: string | null | |||||
| faviconMediaType: string | null | |||||
| faviconOriginalName: string | null | |||||
| faviconSize: number | null | |||||
| faviconSha256: string | null | |||||
| faviconDataVersion: number | |||||
| } | |||||
| const initialConfiguration = (): ConfigurationState => ({ | |||||
| systemName: DEFAULT_SYSTEM_NAME, | |||||
| systemNameConfigured: false, | |||||
| systemNameDataVersion: 0, | |||||
| logoConfigured: false, | |||||
| logoUrl: null, | |||||
| logoMediaType: null, | |||||
| logoOriginalName: null, | |||||
| logoSize: null, | |||||
| logoSha256: null, | |||||
| logoDataVersion: 0, | |||||
| faviconConfigured: false, | |||||
| faviconUrl: null, | |||||
| faviconMediaType: null, | |||||
| faviconOriginalName: null, | |||||
| faviconSize: null, | |||||
| faviconSha256: null, | |||||
| faviconDataVersion: 0, | |||||
| }) | |||||
| const profile = (canUpdate: boolean) => ({ | |||||
| user: { | |||||
| id: '1', | |||||
| username: 'admin', | |||||
| displayName: '系统管理员', | |||||
| departmentId: '10', | |||||
| departmentName: '信息中心', | |||||
| mustChangePassword: false, | |||||
| version: 1, | |||||
| }, | |||||
| activeRoleId: '100', | |||||
| activeRole: { id: '100', code: 'admin', name: '管理员' }, | |||||
| roles: [{ | |||||
| id: '100', code: 'admin', name: '管理员', shortName: '管', status: 1, | |||||
| builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL', | |||||
| }], | |||||
| permissions: ['system.config', ...(canUpdate ? ['system.config.update'] : [])], | |||||
| authorizationMode: 'SINGLE_ACTIVE', | |||||
| loginTime: 1786387200, | |||||
| }) | |||||
| const loginContext = { | |||||
| defaultRoleCode: 'admin', | |||||
| roles: [{ code: 'admin', label: '管理员', accountLabel: '登录账号', organizationMode: 'NONE', organizationLabels: [] }], | |||||
| departments: [], | |||||
| } | |||||
| const envelope = (data: unknown, message = '成功', code: number | string = 200) => JSON.stringify({ | |||||
| code, | |||||
| message, | |||||
| data, | |||||
| timestamp: '2026-08-11T12:00:00+08:00', | |||||
| requestId: 'ute2e-system-config', | |||||
| }) | |||||
| const fulfillJson = (route: Route, data: unknown, status = 200, message = '成功', code: number | string = status) => route.fulfill({ | |||||
| status, | |||||
| contentType: 'application/json', | |||||
| body: envelope(data, message, code), | |||||
| }) | |||||
| class SystemConfigurationMock { | |||||
| readonly state = initialConfiguration() | |||||
| readonly mutations: string[] = [] | |||||
| readonly canUpdate: boolean | |||||
| conflictNextNameSave = false | |||||
| managementReadCount = 0 | |||||
| constructor(canUpdate = true) { | |||||
| this.canUpdate = canUpdate | |||||
| } | |||||
| async install(page: Page, authenticated = true) { | |||||
| if (authenticated) { | |||||
| await page.addInitScript(() => { | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-system-config-token') | |||||
| }) | |||||
| } | |||||
| await page.route('**/api/auth/v1/**', (route) => this.handle(route)) | |||||
| } | |||||
| private publicState() { | |||||
| return { | |||||
| systemName: this.state.systemName, | |||||
| logoConfigured: this.state.logoConfigured, | |||||
| logoUrl: this.state.logoUrl, | |||||
| logoMediaType: this.state.logoMediaType, | |||||
| faviconConfigured: this.state.faviconConfigured, | |||||
| faviconUrl: this.state.faviconUrl, | |||||
| faviconMediaType: this.state.faviconMediaType, | |||||
| } | |||||
| } | |||||
| private configureAsset(asset: BrandAsset) { | |||||
| const nextVersion = this.state[`${asset}DataVersion`] + 1 | |||||
| this.state[`${asset}Configured`] = true | |||||
| this.state[`${asset}Url`] = `/api/auth/v1/system-config/assets/${asset}?v=${nextVersion}` | |||||
| this.state[`${asset}MediaType`] = 'image/png' | |||||
| this.state[`${asset}OriginalName`] = asset === 'logo' ? 'brand-logo.png' : 'site-icon.png' | |||||
| this.state[`${asset}Size`] = PNG_BYTES.length | |||||
| this.state[`${asset}Sha256`] = `mock-${asset}-sha256` | |||||
| this.state[`${asset}DataVersion`] = nextVersion | |||||
| } | |||||
| private removeAsset(asset: BrandAsset) { | |||||
| this.state[`${asset}Configured`] = false | |||||
| this.state[`${asset}Url`] = null | |||||
| this.state[`${asset}MediaType`] = null | |||||
| this.state[`${asset}OriginalName`] = null | |||||
| this.state[`${asset}Size`] = null | |||||
| this.state[`${asset}Sha256`] = null | |||||
| this.state[`${asset}DataVersion`] += 1 | |||||
| } | |||||
| private async handle(route: Route) { | |||||
| const request = route.request() | |||||
| const url = new URL(request.url()) | |||||
| const path = url.pathname | |||||
| const method = request.method() | |||||
| if (method === 'GET' && path === '/api/auth/v1/system-config/public') { | |||||
| return fulfillJson(route, this.publicState()) | |||||
| } | |||||
| if (method === 'GET' && path === '/api/auth/v1/auth/login-context') { | |||||
| return fulfillJson(route, loginContext) | |||||
| } | |||||
| if (method === 'GET' && path === '/api/auth/v1/auth/me') { | |||||
| return fulfillJson(route, profile(this.canUpdate)) | |||||
| } | |||||
| if (method === 'GET' && path === '/api/auth/v1/menus/navigation') { | |||||
| return fulfillJson(route, []) | |||||
| } | |||||
| if (method === 'GET' && path === '/api/auth/v1/system-config') { | |||||
| this.managementReadCount += 1 | |||||
| return fulfillJson(route, this.state) | |||||
| } | |||||
| if (method === 'PUT' && path === '/api/auth/v1/system-config/name') { | |||||
| this.mutations.push('name') | |||||
| if (this.conflictNextNameSave) { | |||||
| this.conflictNextNameSave = false | |||||
| this.state.systemName = '其他管理员刚保存的名称' | |||||
| this.state.systemNameConfigured = true | |||||
| this.state.systemNameDataVersion += 1 | |||||
| return fulfillJson(route, null, 409, '系统名称版本冲突', 'CONFIG_VERSION_CONFLICT') | |||||
| } | |||||
| const body = request.postDataJSON() as { systemName?: string } | |||||
| const nextName = String(body.systemName ?? '').trim() | |||||
| this.state.systemName = nextName || DEFAULT_SYSTEM_NAME | |||||
| this.state.systemNameConfigured = Boolean(nextName) | |||||
| this.state.systemNameDataVersion += 1 | |||||
| return fulfillJson(route, this.state) | |||||
| } | |||||
| if (method === 'POST' && /^\/api\/auth\/v1\/system-config\/(logo|favicon)$/.test(path)) { | |||||
| const asset = path.endsWith('/logo') ? 'logo' : 'favicon' | |||||
| this.mutations.push(`upload-${asset}`) | |||||
| this.configureAsset(asset) | |||||
| return fulfillJson(route, this.state) | |||||
| } | |||||
| if (method === 'DELETE' && /^\/api\/auth\/v1\/system-config\/(logo|favicon)$/.test(path)) { | |||||
| const asset = path.endsWith('/logo') ? 'logo' : 'favicon' | |||||
| this.mutations.push(`remove-${asset}`) | |||||
| this.removeAsset(asset) | |||||
| return fulfillJson(route, this.state) | |||||
| } | |||||
| if (method === 'GET' && /^\/api\/auth\/v1\/system-config\/assets\/(logo|favicon)$/.test(path)) { | |||||
| return route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'image/png', | |||||
| headers: { 'Cache-Control': 'public, max-age=31536000, immutable' }, | |||||
| body: PNG_BYTES, | |||||
| }) | |||||
| } | |||||
| return fulfillJson(route, {}) | |||||
| } | |||||
| } | |||||
| const configuredBrandImage = (page: Page) => page.locator('.platform-brand .brand-mark.configured img') | |||||
| const faviconLink = (page: Page) => page.locator('link[rel~="icon"]') | |||||
| test.describe('系统配置(状态化 Mock,不访问真实 API/数据库)', () => { | |||||
| test('公开配置为空时登录页使用当前内置品牌', async ({ page }) => { | |||||
| const mock = new SystemConfigurationMock() | |||||
| await mock.install(page, false) | |||||
| await page.goto('/login') | |||||
| await expect(page.locator('.login-brand')).toContainText(DEFAULT_SYSTEM_NAME) | |||||
| await expect(page).toHaveTitle(`登录 - ${DEFAULT_SYSTEM_NAME}`) | |||||
| await expect(faviconLink(page)).toHaveAttribute('href', '/favicon.svg') | |||||
| await expect(faviconLink(page)).toHaveAttribute('type', 'image/svg+xml') | |||||
| expect(mock.mutations).toEqual([]) | |||||
| }) | |||||
| test('管理员可从菜单进入并保存、清空名称以及上传、移除 PNG 品牌资源', async ({ page }, testInfo) => { | |||||
| const mock = new SystemConfigurationMock() | |||||
| await mock.install(page) | |||||
| await page.setViewportSize({ width: 1920, height: 1080 }) | |||||
| await page.goto('/system/config') | |||||
| await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true') | |||||
| await expect(page).toHaveURL(/\/system\/config(?:\?|$)/) | |||||
| await expect(page.locator('.current-group-navigation')).toContainText('系统配置') | |||||
| await expect(page.getByRole('link', { name: '系统配置' })).toHaveClass(/active/) | |||||
| await expect(page.getByTestId('system-config-form')).toBeVisible() | |||||
| await expect(page.locator('.config-navigation')).toHaveCount(0) | |||||
| await expect(page.locator('.system-config-form-card')).toHaveCount(1) | |||||
| await expect(page.locator('.system-config-form-row')).toHaveCount(3) | |||||
| const pageBox = await page.getByTestId('system-config-page').boundingBox() | |||||
| const formBox = await page.getByTestId('system-config-form').boundingBox() | |||||
| expect(pageBox).not.toBeNull() | |||||
| expect(formBox).not.toBeNull() | |||||
| expect(formBox!.width / pageBox!.width).toBeGreaterThan(0.95) | |||||
| const nameInput = page.getByTestId('system-config-name') | |||||
| await nameInput.fill('装备维修教学测试平台') | |||||
| await page.getByTestId('system-config-save-name').click() | |||||
| await expect(page.locator('.platform-brand')).toContainText('装备维修教学测试平台') | |||||
| await expect(page).toHaveTitle('系统配置 - 装备维修教学测试平台') | |||||
| await page.getByTestId('system-config-clear-name').click() | |||||
| const restoreNameDialog = page.locator('.el-message-box:visible') | |||||
| await restoreNameDialog.getByRole('button', { name: '恢复默认' }).click() | |||||
| await expect(page.locator('.platform-brand')).toContainText(DEFAULT_SYSTEM_NAME) | |||||
| await expect(page).toHaveTitle(`系统配置 - ${DEFAULT_SYSTEM_NAME}`) | |||||
| const logoCard = page.getByTestId('system-config-logo-card') | |||||
| await logoCard.locator('input[type="file"]').setInputFiles({ | |||||
| name: 'brand-logo.png', | |||||
| mimeType: 'image/png', | |||||
| buffer: PNG_BYTES, | |||||
| }) | |||||
| const logoDialog = page.getByTestId('system-config-upload-dialog') | |||||
| await expect(logoDialog).toBeVisible() | |||||
| await logoDialog.getByRole('button', { name: '确认上传' }).click() | |||||
| await expect(logoCard).toContainText('brand-logo.png') | |||||
| await expect(configuredBrandImage(page)).toHaveAttribute('src', /\/api\/auth\/v1\/system-config\/assets\/logo\?v=1$/) | |||||
| await page.getByTestId('system-config-remove-logo').click() | |||||
| await page.locator('.el-message-box:visible').getByRole('button', { name: '确认移除' }).click() | |||||
| await expect(configuredBrandImage(page)).toHaveCount(0) | |||||
| await expect(logoCard).toContainText('使用系统内置 LOGO') | |||||
| const faviconCard = page.getByTestId('system-config-favicon-card') | |||||
| await faviconCard.locator('input[type="file"]').setInputFiles({ | |||||
| name: 'site-icon.png', | |||||
| mimeType: 'image/png', | |||||
| buffer: PNG_BYTES, | |||||
| }) | |||||
| const faviconDialog = page.getByTestId('system-config-upload-dialog') | |||||
| await faviconDialog.getByRole('button', { name: '确认上传' }).click() | |||||
| await expect(faviconCard).toContainText('site-icon.png') | |||||
| await expect(faviconLink(page)).toHaveAttribute('type', 'image/png') | |||||
| await expect(faviconLink(page)).toHaveAttribute('href', /\/api\/auth\/v1\/system-config\/assets\/favicon\?v=1$/) | |||||
| await page.getByTestId('system-config-remove-favicon').click() | |||||
| await page.locator('.el-message-box:visible').getByRole('button', { name: '确认移除' }).click() | |||||
| await expect(faviconLink(page)).toHaveAttribute('type', 'image/svg+xml') | |||||
| await expect(faviconLink(page)).toHaveAttribute('href', '/favicon.svg') | |||||
| await expect(faviconCard).toContainText('使用系统内置网站图标') | |||||
| expect(mock.mutations).toEqual([ | |||||
| 'name', 'name', 'upload-logo', 'remove-logo', 'upload-favicon', 'remove-favicon', | |||||
| ]) | |||||
| await expect(page.locator('.el-message')).toHaveCount(0, { timeout: 10_000 }) | |||||
| await captureScreenshot(page, testInfo, 'system-config-defaults-restored') | |||||
| }) | |||||
| test('保存遇到 409 时刷新服务端配置并提示重新确认', async ({ page }) => { | |||||
| const mock = new SystemConfigurationMock() | |||||
| mock.conflictNextNameSave = true | |||||
| await mock.install(page) | |||||
| await page.goto('/system/config') | |||||
| await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true') | |||||
| const readsBeforeSave = mock.managementReadCount | |||||
| await page.getByTestId('system-config-name').fill('本次输入会冲突') | |||||
| await page.getByTestId('system-config-save-name').click() | |||||
| await expect(page.getByText('配置已被其他管理员修改,页面已刷新,请确认后重新操作', { exact: true })).toBeVisible() | |||||
| await expect(page.getByTestId('system-config-name')).toHaveValue('其他管理员刚保存的名称') | |||||
| await expect(page.locator('.platform-brand')).toContainText('其他管理员刚保存的名称') | |||||
| await expect(page).toHaveTitle('系统配置 - 其他管理员刚保存的名称') | |||||
| expect(mock.managementReadCount).toBeGreaterThan(readsBeforeSave) | |||||
| }) | |||||
| test('只有查看权限时页面为只读且不暴露更新操作', async ({ page }, testInfo) => { | |||||
| const mock = new SystemConfigurationMock(false) | |||||
| await mock.install(page) | |||||
| await page.goto('/system/config') | |||||
| await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true') | |||||
| await expect(page.getByRole('link', { name: '系统配置' })).toBeVisible() | |||||
| await expect(page.getByTestId('system-config-name')).toBeDisabled() | |||||
| await expect(page.getByTestId('system-config-save-name')).toHaveCount(0) | |||||
| await expect(page.getByTestId('system-config-clear-name')).toHaveCount(0) | |||||
| await expect(page.getByTestId('system-config-upload-logo')).toHaveCount(0) | |||||
| await expect(page.getByTestId('system-config-upload-favicon')).toHaveCount(0) | |||||
| expect(mock.mutations).toEqual([]) | |||||
| await captureScreenshot(page, testInfo, 'system-config-read-only') | |||||
| }) | |||||
| test('简化表单在移动端暗色模式下无横向溢出', async ({ page }, testInfo) => { | |||||
| const mock = new SystemConfigurationMock() | |||||
| await mock.install(page) | |||||
| await page.addInitScript(() => { | |||||
| localStorage.setItem('unreal-tran:web:preferences:v1', JSON.stringify({ | |||||
| navigationMode: 'top-side', | |||||
| themeMode: 'dark', | |||||
| sidebarCollapsed: false, | |||||
| })) | |||||
| }) | |||||
| await page.setViewportSize({ width: 375, height: 812 }) | |||||
| await page.goto('/system/config') | |||||
| await expect(page.getByTestId('system-config-page')).toHaveAttribute('data-ready', 'true') | |||||
| await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') | |||||
| await expect(page.getByTestId('system-config-form')).toBeVisible() | |||||
| await expect(page.locator('.config-navigation')).toHaveCount(0) | |||||
| expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) | |||||
| await captureScreenshot(page, testInfo, 'system-config-mobile-dark') | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,223 @@ | |||||
| import type { Page } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { | |||||
| chooseSelectOption, | |||||
| confirmMessageBox, | |||||
| dismissOverlays, | |||||
| fillFormItem, | |||||
| loginAsAdmin, | |||||
| roleCardByText, | |||||
| tableRowByText, | |||||
| visibleDialog, | |||||
| } from './helpers' | |||||
| const runToken = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` | |||||
| const upperToken = runToken.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-14) | |||||
| const lowerToken = runToken.replace(/[^a-z0-9]/gi, '').toLowerCase().slice(-18) | |||||
| const data = { | |||||
| prefix: `UTE2E-${upperToken}`, | |||||
| departmentName: `UTE2E-${upperToken}-部门`, | |||||
| departmentCode: `E2E_${upperToken}`.slice(0, 32), | |||||
| roleName: `UTE2E-${upperToken}-角色`, | |||||
| roleCode: `e2e_${lowerToken}`.slice(0, 48), | |||||
| roleShort: '测', | |||||
| userName: `e2e_${lowerToken}`.slice(0, 32), | |||||
| displayName: `UTE2E-${upperToken}-用户`, | |||||
| editedDisplayName: `UTE2E-${upperToken}-已编辑`, | |||||
| password: `Tmp@${upperToken}a1`, | |||||
| } | |||||
| async function createDepartment(page: Page) { | |||||
| await page.goto('/system/departments') | |||||
| await page.getByRole('button', { name: '新增部门', exact: true }).click() | |||||
| const dialog = visibleDialog(page) | |||||
| await fillFormItem(dialog, '部门名称', data.departmentName) | |||||
| await fillFormItem(dialog, '部门编码', data.departmentCode) | |||||
| await fillFormItem(dialog, '负责人', data.prefix) | |||||
| await fillFormItem(dialog, '部门职责', `${data.prefix} 自动化测试数据`) | |||||
| await dialog.getByRole('button', { name: '创建部门' }).click() | |||||
| await expect(tableRowByText(page, data.departmentName)).toBeVisible() | |||||
| } | |||||
| async function createRole(page: Page) { | |||||
| await page.goto('/system/roles') | |||||
| await page.getByRole('button', { name: '新增角色', exact: true }).click() | |||||
| const dialog = visibleDialog(page) | |||||
| await fillFormItem(dialog, '角色名称', data.roleName) | |||||
| await fillFormItem(dialog, '角色编码', data.roleCode) | |||||
| await fillFormItem(dialog, '界面简称', data.roleShort) | |||||
| await fillFormItem(dialog, '角色说明', `${data.prefix} 自动化角色`) | |||||
| await dialog.getByRole('button', { name: '创建角色' }).click() | |||||
| await expect(roleCardByText(page, data.roleName)).toBeVisible() | |||||
| } | |||||
| async function createUser(page: Page) { | |||||
| await page.goto('/system/users') | |||||
| await page.getByRole('button', { name: '新增用户', exact: true }).click() | |||||
| const dialog = visibleDialog(page) | |||||
| await fillFormItem(dialog, '姓名', data.displayName) | |||||
| await fillFormItem(dialog, '登录账号', data.userName) | |||||
| await chooseSelectOption(page, dialog, '所属部门', data.departmentName) | |||||
| await fillFormItem(dialog, '初始密码', data.password) | |||||
| const roleCheckbox = dialog.locator('.el-checkbox').filter({ hasText: data.roleName }) | |||||
| await roleCheckbox.click() | |||||
| await chooseSelectOption(page, dialog, '登录默认身份', data.roleName) | |||||
| await dialog.getByRole('button', { name: '创建用户' }).click() | |||||
| await expect(tableRowByText(page, data.userName)).toBeVisible() | |||||
| } | |||||
| async function editAndSearchData(page: Page) { | |||||
| await page.goto('/system/users') | |||||
| await page.getByPlaceholder('请输入姓名、账号、部门或角色').fill(data.userName) | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| const userRow = tableRowByText(page, data.userName) | |||||
| await expect(userRow).toBeVisible() | |||||
| await userRow.getByRole('button', { name: '编辑' }).click() | |||||
| const userDialog = visibleDialog(page) | |||||
| await fillFormItem(userDialog, '姓名', data.editedDisplayName) | |||||
| await userDialog.getByRole('button', { name: '保存修改' }).click() | |||||
| await expect(tableRowByText(page, data.editedDisplayName)).toBeVisible() | |||||
| await page.goto('/system/roles') | |||||
| await page.getByPlaceholder('请输入角色名称、编码或说明').fill(data.roleCode) | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| const roleCard = roleCardByText(page, data.roleName) | |||||
| await expect(roleCard).toBeVisible() | |||||
| await roleCard.getByRole('button', { name: '编辑' }).click() | |||||
| const roleDialog = visibleDialog(page) | |||||
| await fillFormItem(roleDialog, '角色说明', `${data.prefix} 已编辑`) | |||||
| await roleDialog.getByRole('button', { name: '保存修改' }).click() | |||||
| await expect(roleCardByText(page, `${data.prefix} 已编辑`)).toBeVisible() | |||||
| await page.goto('/system/departments') | |||||
| await page.getByPlaceholder('请输入部门、编码、负责人或层级路径').fill(data.departmentCode) | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| await expect(tableRowByText(page, data.departmentName)).toBeVisible() | |||||
| } | |||||
| async function grantPermission(page: Page) { | |||||
| await page.goto('/system/permissions') | |||||
| const roleButton = page.locator('.role-selector > button').filter({ hasText: data.roleName }) | |||||
| await Promise.all([ | |||||
| page.waitForResponse((response) => response.request().method() === 'GET' && /\/permissions\/roles\/[^/?]+/.test(response.url()) && response.ok()), | |||||
| roleButton.click(), | |||||
| ]) | |||||
| await expect(roleButton).toHaveClass(/active/) | |||||
| await expect(page.locator('.selected-role-copy h2')).toHaveText(data.roleName) | |||||
| const auditCode = page.locator('code').filter({ hasText: /^system\.audit$/ }) | |||||
| const auditPermission = page.locator('.permission-item').filter({ has: auditCode }) | |||||
| await expect(auditPermission).toHaveAttribute('aria-checked', 'false') | |||||
| await auditPermission.click() | |||||
| const saveButton = page.getByRole('button', { name: '保存权限配置' }) | |||||
| await expect(saveButton).toBeEnabled() | |||||
| await saveButton.click() | |||||
| await expect(page.getByText(/权限已保存|当前权限配置已保存/).last()).toBeVisible() | |||||
| } | |||||
| async function verifyAudit(page: Page) { | |||||
| await page.goto('/system/audit') | |||||
| const search = page.getByPlaceholder('请输入用户、模块、操作或地址') | |||||
| await search.fill(data.prefix) | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| await expect.poll(async () => page.locator('.el-table__body-wrapper .el-table__row').count(), { | |||||
| message: '等待与本次唯一前缀相关的审计记录可见', | |||||
| timeout: 10_000, | |||||
| }).toBeGreaterThan(0) | |||||
| } | |||||
| async function deleteUserIfPresent(page: Page) { | |||||
| await dismissOverlays(page) | |||||
| await page.goto('/system/users') | |||||
| await page.getByPlaceholder('请输入姓名、账号、部门或角色').fill(data.userName) | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| const row = tableRowByText(page, data.userName) | |||||
| if (!(await row.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false))) return false | |||||
| await row.getByRole('button', { name: '删除' }).click() | |||||
| await confirmMessageBox(page, '确认删除') | |||||
| await expect(row).toHaveCount(0) | |||||
| return true | |||||
| } | |||||
| async function deleteRoleIfPresent(page: Page) { | |||||
| await dismissOverlays(page) | |||||
| await page.goto('/system/roles') | |||||
| await page.getByPlaceholder('请输入角色名称、编码或说明').fill(data.roleCode) | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| const card = roleCardByText(page, data.roleName) | |||||
| if (!(await card.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false))) return false | |||||
| await card.getByRole('button', { name: '删除' }).click() | |||||
| await confirmMessageBox(page, '删除角色') | |||||
| await expect(card).toHaveCount(0) | |||||
| return true | |||||
| } | |||||
| async function deleteDepartmentIfPresent(page: Page) { | |||||
| await dismissOverlays(page) | |||||
| await page.goto('/system/departments') | |||||
| await page.getByPlaceholder('请输入部门、编码、负责人或层级路径').fill(data.departmentCode) | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| const row = tableRowByText(page, data.departmentName) | |||||
| if (!(await row.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false))) return false | |||||
| await row.getByRole('button', { name: '删除' }).click() | |||||
| const dialog = visibleDialog(page) | |||||
| await dialog.getByRole('button', { name: '确认删除并迁移' }).click() | |||||
| await expect(row).toHaveCount(0) | |||||
| return true | |||||
| } | |||||
| test('系统管理测试数据可创建、编辑、搜索、授权、审计并完整清理', async ({ page }, testInfo) => { | |||||
| await loginAsAdmin(page) | |||||
| let testFailure: unknown | |||||
| try { | |||||
| await test.step('创建测试部门', async () => { | |||||
| await createDepartment(page) | |||||
| await captureScreenshot(page, testInfo, 'crud-department-created') | |||||
| }) | |||||
| await test.step('创建测试角色', async () => { | |||||
| await createRole(page) | |||||
| await captureScreenshot(page, testInfo, 'crud-role-created') | |||||
| }) | |||||
| await test.step('创建测试用户', async () => { | |||||
| await createUser(page) | |||||
| await captureScreenshot(page, testInfo, 'crud-user-created') | |||||
| }) | |||||
| await test.step('编辑并搜索测试数据', async () => { | |||||
| await editAndSearchData(page) | |||||
| await captureScreenshot(page, testInfo, 'crud-edit-search-success') | |||||
| }) | |||||
| await test.step('配置角色权限', async () => { | |||||
| await grantPermission(page) | |||||
| await captureScreenshot(page, testInfo, 'crud-permission-saved') | |||||
| }) | |||||
| await test.step('审计记录可检索', async () => { | |||||
| await verifyAudit(page) | |||||
| await captureScreenshot(page, testInfo, 'crud-audit-found') | |||||
| }) | |||||
| } catch (error) { | |||||
| testFailure = error | |||||
| } finally { | |||||
| const cleanupErrors: unknown[] = [] | |||||
| for (const [name, screenshotLabel, cleanup] of [ | |||||
| ['用户', 'crud-user-deleted', deleteUserIfPresent], | |||||
| ['角色', 'crud-role-deleted', deleteRoleIfPresent], | |||||
| ['部门', 'crud-department-deleted', deleteDepartmentIfPresent], | |||||
| ] as const) { | |||||
| try { | |||||
| const deleted = await cleanup(page) | |||||
| if (!testFailure && deleted) await captureScreenshot(page, testInfo, screenshotLabel) | |||||
| } catch (error) { | |||||
| cleanupErrors.push(new Error(`${name}清理失败`, { cause: error })) | |||||
| } | |||||
| } | |||||
| if (testFailure || cleanupErrors.length) { | |||||
| throw new AggregateError( | |||||
| [testFailure, ...cleanupErrors].filter((error) => error !== undefined), | |||||
| cleanupErrors.length ? '系统管理 E2E 失败,且已执行全部清理步骤;请检查聚合错误。' : '系统管理 E2E 失败。', | |||||
| ) | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -0,0 +1,93 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const json = (route: Route, data: unknown) => route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ code: 0, message: '成功', data, timestamp: Date.now(), requestId: 'inline-statistics-mock' }), | |||||
| }) | |||||
| const departments = [{ | |||||
| id: '100', code: 'ORG-100', name: '某类装备维修实训数字车间', parentId: null, enabled: true, isRoot: true, | |||||
| sortOrder: 10, directUserCount: 1, directChildCount: 1, version: 1, | |||||
| children: [{ | |||||
| id: '110', code: 'ORG-110', name: '信息中心', parentId: '100', enabled: true, | |||||
| sortOrder: 10, directUserCount: 3, directChildCount: 0, version: 1, children: [], | |||||
| }], | |||||
| }] | |||||
| const roles = [ | |||||
| { id: '1', code: 'admin', name: '管理员', shortName: '管', enabled: true, builtIn: true, isSuperAdmin: true, dataScopeCode: 'ALL', linkedUserCount: 1, permissionCount: 30, version: 1 }, | |||||
| { id: '2', code: 'teacher', name: '教员', shortName: '教', enabled: true, builtIn: true, isSuperAdmin: false, dataScopeCode: 'SYSTEM', linkedUserCount: 3, permissionCount: 12, version: 1 }, | |||||
| ] | |||||
| const menus = [{ | |||||
| id: 'group-system', parentId: null, code: 'group.system', name: '系统管理', description: '平台与权限配置', | |||||
| type: 'GROUP', sortOrder: 10, enabled: true, builtIn: true, dataVersion: 1, | |||||
| children: [ | |||||
| { id: 'system-users', parentId: 'group-system', code: 'system.users', name: '用户管理', description: '管理用户', type: 'PAGE', sortOrder: 10, enabled: true, builtIn: true, dataVersion: 1, children: [] }, | |||||
| { id: 'system-roles', parentId: 'group-system', code: 'system.roles', name: '角色管理', description: '管理角色', type: 'PAGE', sortOrder: 20, enabled: true, builtIn: true, dataVersion: 1, children: [] }, | |||||
| ], | |||||
| }] | |||||
| async function mockSystemPageApi(page: Page) { | |||||
| await page.route(/\/api\/(?:auth|tran)\//, async (route) => { | |||||
| const path = new URL(route.request().url()).pathname | |||||
| if (path.endsWith('/auth/me')) return json(route, { | |||||
| userId: '1', username: 'admin', displayName: '系统管理员', departmentId: '110', departmentName: '信息中心', | |||||
| activeRoleId: '1', roles, authorizationMode: 'SINGLE_ACTIVE', mustChangePassword: false, | |||||
| permissions: ['system.departments', 'system.roles', 'system.menus', 'system.permissions', 'system.departments.create', 'system.roles.create', 'system.roles.update', 'system.roles.delete', 'system.menus.update'], | |||||
| }) | |||||
| if (path.endsWith('/departments/tree')) return json(route, departments) | |||||
| if (path.endsWith('/departments/stats')) return json(route, { total: 2, enabled: 2, maxDepth: 2, assignedUsers: 4 }) | |||||
| if (path.endsWith('/roles/all')) return json(route, roles) | |||||
| if (path.endsWith('/roles/stats')) return json(route, { total: 2, enabled: 2, builtIn: 2, assignedUsers: 4 }) | |||||
| if (path.endsWith('/menus/tree') || path.endsWith('/menus/navigation')) return json(route, menus) | |||||
| if (path.endsWith('/system-config/public')) return json(route, { systemName: '某类装备维修实训数字车间', logoConfigured: false, faviconConfigured: false }) | |||||
| if (path.endsWith('/auth/ping')) return json(route, { service: 'ut-auth', status: 'UP' }) | |||||
| if (path.endsWith('/tran/ping')) return json(route, { service: 'ut-tran', status: 'UP' }) | |||||
| return json(route, {}) | |||||
| }) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('unreal-tran:web:access-token:v1', 'inline-statistics-token') | |||||
| }) | |||||
| } | |||||
| test('部门、角色、菜单统计收进列表工具栏', async ({ page }, testInfo) => { | |||||
| await mockSystemPageApi(page) | |||||
| const cases = [ | |||||
| { path: '/system/departments', testId: 'department-inline-statistics', removed: '.department-stats', texts: ['共 2 个部门', '启用 2', '2 级组织', '归属 4 人'] }, | |||||
| { path: '/system/roles', testId: 'role-inline-statistics', removed: '.role-statistics', texts: ['共 2 个角色', '启用 2', '内置 2', '已分配 4 人'] }, | |||||
| { path: '/system/menus', testId: 'menu-inline-statistics', removed: '.menu-statistics', texts: ['共 3 个节点', '分组 1', '页面 2'] }, | |||||
| ] as const | |||||
| for (const item of cases) { | |||||
| await page.goto(item.path) | |||||
| const statistics = page.getByTestId(item.testId) | |||||
| await expect(statistics).toBeVisible() | |||||
| await expect(page.locator(item.removed)).toHaveCount(0) | |||||
| for (const text of item.texts) await expect(statistics).toContainText(text) | |||||
| await expect(statistics.locator('.is-danger')).toHaveCount(0) | |||||
| const toolbarHeight = await statistics.locator('xpath=ancestor::*[contains(@class, "system-list-toolbar")]').evaluate((toolbar) => toolbar.getBoundingClientRect().height) | |||||
| expect(toolbarHeight).toBeLessThanOrEqual(64) | |||||
| if (item.path === '/system/roles') { | |||||
| const table = page.locator('.roles-table') | |||||
| await expect(page.locator('.role-card')).toHaveCount(0) | |||||
| await expect(table).toHaveClass(/el-table--border/) | |||||
| await expect(table).toHaveClass(/el-table--striped/) | |||||
| await expect(table.locator('.el-table__body-wrapper .el-table__row')).toHaveCount(2) | |||||
| const actions = table.locator('.role-table-actions').first() | |||||
| await expect(actions.getByRole('button', { name: '配置权限' })).toBeVisible() | |||||
| await expect(actions.getByRole('button', { name: '编辑' })).toBeVisible() | |||||
| const actionLayout = await actions.evaluate((element) => { | |||||
| const style = getComputedStyle(element) | |||||
| return { justifyContent: style.justifyContent, columnGap: style.columnGap } | |||||
| }) | |||||
| expect(actionLayout.justifyContent).toBe('flex-start') | |||||
| expect(actionLayout.columnGap).toBe('12px') | |||||
| } | |||||
| await captureScreenshot(page, testInfo, item.testId) | |||||
| } | |||||
| }) | |||||
| @@ -0,0 +1,88 @@ | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { loginAsAdmin } from './helpers' | |||||
| const pages = [ | |||||
| ['/system/users', '用户管理', 'system-users', '.users-table .el-table__body-wrapper .el-table__row'], | |||||
| ['/system/departments', '部门管理', 'system-departments', '.department-table .el-table__body-wrapper .el-table__row'], | |||||
| ['/system/roles', '角色管理', 'system-roles', '.roles-table .el-table__body-wrapper .el-table__row'], | |||||
| ['/system/menus', '菜单管理', 'system-menus', '.menu-table-panel .el-table__body-wrapper .el-table__row'], | |||||
| ['/system/permissions', '权限管理', 'system-permissions', '.permission-group'], | |||||
| ['/system/audit', '审计日志', 'system-audit', '.el-table__body-wrapper .el-table__row'], | |||||
| ['/system/interfaces', '平台服务配置', 'system-interfaces', '.interfaces-table .el-table__body-wrapper .el-table__row'], | |||||
| ] as const | |||||
| test('系统管理七个页面均可加载', async ({ page }, testInfo) => { | |||||
| await loginAsAdmin(page) | |||||
| for (const [path, heading, screenshotLabel, readySelector] of pages) { | |||||
| await test.step(heading, async () => { | |||||
| await page.goto(path) | |||||
| await expect(page).toHaveURL(new RegExp(`${path.replaceAll('/', '\\/')}(?:\\?|$)`)) | |||||
| // 系统管理页面已去掉 SystemPageHeader,页面身份由页面标签栏承担 | |||||
| await expect(page.locator('.system-page-header')).toHaveCount(0) | |||||
| await expect(page.getByRole('tab', { name: heading, exact: true })).toBeVisible() | |||||
| await expect(page.locator(readySelector).first()).toBeVisible({ timeout: 15_000 }) | |||||
| await expect(page.locator('.system-page .el-loading-mask:visible')).toHaveCount(0) | |||||
| if (['/system/users', '/system/departments', '/system/roles', '/system/menus', '/system/audit', '/system/interfaces'].includes(path)) { | |||||
| await expect(page.getByRole('button', { name: '搜索', exact: true })).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: '重置', exact: true })).toBeVisible() | |||||
| // 关键词框统一 260px | |||||
| const keywordWidth = await page.locator('.system-list-toolbar .el-input').first() | |||||
| .evaluate((element) => element.getBoundingClientRect().width) | |||||
| expect(keywordWidth).toBeGreaterThan(230) | |||||
| expect(keywordWidth).toBeLessThanOrEqual(270) | |||||
| } | |||||
| // 页级动作落在工具条右端,不再出现在页头 | |||||
| for (const [actionPath, actionName] of [ | |||||
| ['/system/users', '新增用户'], | |||||
| ['/system/departments', '新增部门'], | |||||
| ['/system/roles', '新增角色'], | |||||
| ['/system/audit', '导出日志'], | |||||
| ] as const) { | |||||
| if (path !== actionPath) continue | |||||
| const action = page.locator('.system-list-toolbar').getByRole('button', { name: actionName, exact: true }) | |||||
| await expect(action).toBeVisible() | |||||
| const [toolbarBox, actionBox] = await Promise.all([ | |||||
| page.locator('.system-list-toolbar').first().boundingBox(), | |||||
| action.boundingBox(), | |||||
| ]) | |||||
| expect(actionBox!.x + actionBox!.width).toBeGreaterThan(toolbarBox!.x + toolbarBox!.width - 40) | |||||
| } | |||||
| if (path === '/system/departments') { | |||||
| await expect(page.locator('.department-stats')).toHaveCount(0) | |||||
| await expect(page.getByTestId('department-inline-statistics')).toContainText('个部门') | |||||
| await expect(page.getByTestId('department-inline-statistics')).toContainText('启用') | |||||
| } | |||||
| if (path === '/system/roles') { | |||||
| await expect(page.locator('.role-statistics')).toHaveCount(0) | |||||
| await expect(page.getByTestId('role-inline-statistics')).toContainText('个角色') | |||||
| await expect(page.getByTestId('role-inline-statistics')).toContainText('内置') | |||||
| } | |||||
| if (path === '/system/menus') { | |||||
| await expect(page.locator('.menu-statistics')).toHaveCount(0) | |||||
| await expect(page.getByTestId('menu-inline-statistics')).toContainText('个节点') | |||||
| await expect(page.getByTestId('menu-inline-statistics')).toContainText('页面') | |||||
| } | |||||
| if (path === '/system/interfaces') { | |||||
| // 卡片式展示已改为搜索栏 + 表格 + 分页 | |||||
| await expect(page.locator('.interface-card')).toHaveCount(0) | |||||
| await expect(page.locator('.interfaces-table')).toHaveClass(/el-table--border/) | |||||
| await expect(page.locator('.interfaces-table')).toHaveClass(/el-table--striped/) | |||||
| await expect(page.getByTestId('interface-inline-statistics')).toContainText('个服务') | |||||
| await expect(page.locator('.system-pagination-total')).toBeVisible() | |||||
| } | |||||
| if (path === '/system/permissions') { | |||||
| const permissionCopies = page.locator('.permission-item .permission-copy') | |||||
| await expect(permissionCopies.first()).toBeVisible() | |||||
| expect(await permissionCopies.count()).toBeGreaterThan(20) | |||||
| const incomplete = await permissionCopies.evaluateAll((items) => items.filter((item) => { | |||||
| const title = item.querySelector('b')?.textContent?.trim() | |||||
| const code = item.querySelector('code')?.textContent?.trim() | |||||
| return !title || !code | |||||
| }).length) | |||||
| expect(incomplete).toBe(0) | |||||
| } | |||||
| await captureScreenshot(page, testInfo, screenshotLabel) | |||||
| }) | |||||
| } | |||||
| }) | |||||
| @@ -0,0 +1,681 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| export type TeachingIdentity = 'admin' | 'administrative' | 'teacher' | 'studentLeader' | 'studentOperator' | 'unionAdministrativeStudent' | |||||
| type Channel = 'VIRTUAL' | 'PHYSICAL' | 'CONFRONTATION' | |||||
| type AssignmentKind = 'TRAINING' | 'FORMAL_EXAM' | |||||
| type JsonRecord = Record<string, unknown> | |||||
| const stepCodeOf = (step: JsonRecord) => String(step.stepCode ?? step.code ?? step.id ?? '') | |||||
| export interface RecordedTeachingRequest { | |||||
| method: string | |||||
| path: string | |||||
| query: URLSearchParams | |||||
| body: JsonRecord | |||||
| } | |||||
| const allTeachingPages = [ | |||||
| 'teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', | |||||
| 'teaching.immersive', 'teaching.exams', 'teaching.analytics', | |||||
| ] | |||||
| const managerActions = ['teaching.tasks.create', 'teaching.tasks.publish', 'teaching.tasks.withdraw', 'teaching.tasks.review', 'teaching.tasks.archive'] | |||||
| const learnerActions = ['teaching.tasks.accept', 'teaching.tasks.submit', 'teaching.virtual.execute', 'teaching.physical.execute', 'teaching.confrontation.execute', 'teaching.immersive.execute'] | |||||
| const permissions: Record<TeachingIdentity, string[]> = { | |||||
| admin: [...allTeachingPages, ...managerActions, ...learnerActions, 'teaching.confrontation.configure'], | |||||
| administrative: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.immersive', 'teaching.exams', 'teaching.analytics'], | |||||
| teacher: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.immersive', 'teaching.exams', 'teaching.analytics', ...managerActions, 'teaching.confrontation.configure', 'teaching.immersive.execute'], | |||||
| studentLeader: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', ...learnerActions], | |||||
| studentOperator: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', ...learnerActions], | |||||
| unionAdministrativeStudent: ['teaching.virtual', 'teaching.physical', 'teaching.confrontation', 'teaching.guides', 'teaching.immersive', 'teaching.exams', 'teaching.analytics', ...learnerActions], | |||||
| } | |||||
| const roleNames = { admin: '管理员', administrative: '行政', teacher: '教员', student: '学员' } | |||||
| const member = ( | |||||
| id: string, | |||||
| userId: string, | |||||
| username: string, | |||||
| displayName: string, | |||||
| memberType: 'INSTRUCTOR' | 'LEARNER' | 'OBSERVER', | |||||
| positionCode: string, | |||||
| teamCode: 'NEUTRAL' | 'RED' | 'BLUE', | |||||
| groupCode: string, | |||||
| sortOrder: number, | |||||
| ) => ({ | |||||
| id, userId, username, displayName, departmentId: '110', departmentName: '维修教研室', | |||||
| memberType, roleCode: memberType === 'INSTRUCTOR' ? 'teacher' : memberType === 'LEARNER' ? 'student' : 'observer', | |||||
| teamCode, groupCode, positionCode, stationCode: '', sortOrder, version: 1, | |||||
| }) | |||||
| export const teachingMembers = { | |||||
| instructor: member('member-teacher', '2', 'teacher', '周教员', 'INSTRUCTOR', 'INSTRUCTOR', 'NEUTRAL', 'INSTRUCTORS', 0), | |||||
| leader: member('member-red-leader', '4', 'student.leader', '红方队长', 'LEARNER', 'TEAM_LEADER', 'RED', 'GROUP-RED', 1), | |||||
| operator: member('member-red-operator', '5', 'student.operator', '红方操作员', 'LEARNER', 'OPERATOR', 'RED', 'GROUP-RED', 2), | |||||
| commander: member('member-blue-commander', '7', 'student.commander', '蓝方指挥员', 'LEARNER', 'COMMANDER', 'BLUE', 'GROUP-BLUE', 3), | |||||
| observer: member('member-observer', '6', 'observer', '安全观察员', 'OBSERVER', 'OBSERVER', 'NEUTRAL', 'OBSERVERS', 4), | |||||
| } | |||||
| const twoStepDefinition = (channel: Channel) => ({ | |||||
| schema: 'unreal-tran.training', | |||||
| version: '2.0', | |||||
| scenario: { | |||||
| schema: 'unreal-tran.scene', version: '2.0', | |||||
| objects: [{ | |||||
| id: `${channel.toLowerCase()}-equipment`, type: 'model', name: '履带式挖掘机训练模型', | |||||
| assetUrl: 'content://sha256/contract-excavator', assetCode: 'EXCAVATOR_A', | |||||
| targetProjectId: 'asset-project-1', targetVersionId: 'asset-version-1', | |||||
| position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], visible: true, | |||||
| semantic: { interactionId: 'excavator', operable: true }, | |||||
| }], | |||||
| }, | |||||
| steps: channel === 'PHYSICAL' | |||||
| ? [ | |||||
| { code: 'STEP-01', title: '确认设备断电', publicSummary: '完成现场断电与挂牌。', targetId: `${channel.toLowerCase()}-equipment`, physicalExecution: { source: 'MANUAL', eventType: 'MANUAL_CONFIRM_POWER_OFF', manualConfirmAllowed: true } }, | |||||
| { code: 'STEP-02', title: '确认液压卸压', publicSummary: '完成液压系统卸压。', targetId: `${channel.toLowerCase()}-equipment`, physicalExecution: { source: 'MANUAL', eventType: 'MANUAL_CONFIRM_PRESSURE_RELEASED', manualConfirmAllowed: true } }, | |||||
| ] | |||||
| : channel === 'CONFRONTATION' | |||||
| ? [ | |||||
| { code: 'STEP-01', title: '队长下达处置指令', publicSummary: '共享任务由队长发起。', actionCode: 'ISSUE_COMMAND', allowedPositions: ['TEAM_LEADER'], targetId: `${channel.toLowerCase()}-equipment` }, | |||||
| { code: 'STEP-02', title: '操作员执行故障隔离', publicSummary: '操作员按指令隔离故障。', actionCode: 'ISOLATE_FAULT', requiredPositions: ['OPERATOR'], targetId: `${channel.toLowerCase()}-equipment` }, | |||||
| ] | |||||
| : [ | |||||
| { stepCode: 'STEP-01', title: '识别作业对象', publicSummary: '确认训练对象与安全边界。', actionCode: 'IDENTIFY_TARGET', targetId: `${channel.toLowerCase()}-equipment` }, | |||||
| { stepCode: 'STEP-02', title: '完成检修操作', publicSummary: '按流程完成检修并复核。', actionCode: 'COMPLETE_REPAIR', targetId: `${channel.toLowerCase()}-equipment` }, | |||||
| ], | |||||
| runtime: { submitPositionCodes: channel === 'CONFRONTATION' ? ['TEAM_LEADER', 'COMMANDER'] : [] }, | |||||
| modeConfig: channel === 'CONFRONTATION' ? { | |||||
| objective: '红蓝双方按岗位协同完成液压故障隔离', | |||||
| roundDurationMinutes: 20, | |||||
| teamSize: 2, | |||||
| winRule: '在安全约束下按完成度与用时综合判定', | |||||
| redTeamName: '红方维修组', | |||||
| blueTeamName: '蓝方保障组', | |||||
| submitPositions: ['TEAM_LEADER', 'COMMANDER'], | |||||
| teams: [ | |||||
| { code: 'RED', name: '红方维修组', color: '#d25c5c', size: 2 }, | |||||
| { code: 'BLUE', name: '蓝方保障组', color: '#4b7ed6', size: 2 }, | |||||
| ], | |||||
| } : {}, | |||||
| }) | |||||
| const assignment = (id: string, channel: Channel, kind: AssignmentKind, audienceType: 'ASSIGNED' | 'COMMON', collaborationMode: 'INDIVIDUAL' | 'TEAM') => ({ | |||||
| id, | |||||
| code: kind === 'FORMAL_EXAM' ? 'EXAM-2026-001' : `TASK-${channel}-001`, | |||||
| name: kind === 'FORMAL_EXAM' ? `${channel === 'PHYSICAL' ? '实装' : channel === 'CONFRONTATION' ? '对抗' : '虚拟'}维修正式考核` : `${channel === 'PHYSICAL' ? '实装' : channel === 'CONFRONTATION' ? '对抗' : '虚拟'}维修训练`, | |||||
| description: '状态化 UI 契约测试任务。', channel, assignmentKind: kind, | |||||
| executionMode: kind === 'FORMAL_EXAM' ? 'EXAM' : 'PRACTICE', audienceType, collaborationMode, | |||||
| contentProjectId: 'training-project-1', contentVersionId: 'training-version-1', | |||||
| definitionSnapshot: twoStepDefinition(channel), definitionChecksum: `checksum-${id}`, | |||||
| digitalHumanAllowed: kind !== 'FORMAL_EXAM', ownerUserId: '2', ownerUsername: 'teacher', ownerName: '周教员', | |||||
| departmentId: '110', departmentName: '维修教研室', status: 'PUBLISHED', | |||||
| scheduleStartAt: 1786383600, dueAt: 1786473600, publishedAt: 1786380000, | |||||
| withdrawnAt: null, archivedAt: null, lifecycleReason: '', version: 3, | |||||
| addTime: 1786300800, updateTime: '2026-08-11T08:00:00+08:00', serverNow: 1786387200, | |||||
| members: audienceType === 'COMMON' | |||||
| ? [teachingMembers.instructor] | |||||
| : channel === 'CONFRONTATION' | |||||
| ? Object.values(teachingMembers) | |||||
| : [teachingMembers.instructor, teachingMembers.leader], | |||||
| runCount: 1, pendingReviewCount: 0, | |||||
| }) | |||||
| const assignmentBrief = (item: JsonRecord) => ({ | |||||
| id: item.id, code: item.code, name: item.name, channel: item.channel, assignmentKind: item.assignmentKind, | |||||
| executionMode: item.executionMode, audienceType: item.audienceType, collaborationMode: item.collaborationMode, | |||||
| digitalHumanAllowed: item.digitalHumanAllowed, status: item.status, | |||||
| scheduleStartAt: item.scheduleStartAt, dueAt: item.dueAt, serverNow: item.serverNow, | |||||
| }) | |||||
| const runFor = (id: string, task: JsonRecord, status: 'PENDING' | 'ACCEPTED' | 'IN_PROGRESS' | 'SUBMITTED' | 'REVIEWED', learner = teachingMembers.leader) => ({ | |||||
| id, code: `RUN-${String(task.channel)}-${id.slice(-3).toUpperCase()}`, assignmentId: task.id, | |||||
| memberId: learner.id, subjectType: task.collaborationMode === 'TEAM' ? 'TEAM' : 'MEMBER', | |||||
| subjectCode: task.collaborationMode === 'TEAM' ? learner.teamCode : learner.id, | |||||
| attemptNo: 1, status, progressPercent: status === 'SUBMITTED' || status === 'REVIEWED' ? 100 : 0, | |||||
| currentStepCode: 'STEP-01', checkpoint: {}, automaticResult: {}, submission: status === 'SUBMITTED' || status === 'REVIEWED' ? { summary: '已完成' } : null, | |||||
| score: status === 'REVIEWED' ? 88 : null, passed: status === 'REVIEWED' ? true : null, | |||||
| reviewFeedback: status === 'REVIEWED' ? '流程与操作符合要求。' : '', reviewRubric: {}, | |||||
| acceptedAt: status === 'PENDING' ? null : 1786386000, startedAt: ['PENDING', 'ACCEPTED'].includes(status) ? null : 1786387200, | |||||
| submittedAt: status === 'SUBMITTED' || status === 'REVIEWED' ? 1786390800 : null, | |||||
| reviewedAt: status === 'REVIEWED' ? 1786394400 : null, reviewedByUserId: status === 'REVIEWED' ? '2' : null, | |||||
| reviewerName: status === 'REVIEWED' ? '周教员' : '', archivedAt: null, version: 2, | |||||
| addTime: 1786386000, updateTime: '2026-08-11T10:00:00+08:00', assignment: assignmentBrief(task), | |||||
| member: learner, members: task.collaborationMode === 'TEAM' ? Object.values(teachingMembers) : [teachingMembers.instructor, learner], | |||||
| }) | |||||
| const envelope = (data: unknown, requestId = 'ute2e-teaching-state') => ({ | |||||
| code: 200, message: '成功', data, timestamp: '2026-08-11T10:00:00+08:00', requestId, | |||||
| }) | |||||
| const pageResult = (records: unknown[], page = 1, size = 10) => ({ records, total: records.length, page, size }) | |||||
| const bodyOf = (route: Route): JsonRecord => { | |||||
| const text = route.request().postData() | |||||
| if (!text) return {} | |||||
| const value = JSON.parse(text) as unknown | |||||
| return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {} | |||||
| } | |||||
| const fulfill = (route: Route, data: unknown, requestId?: string) => route.fulfill({ | |||||
| status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data, requestId)), | |||||
| }) | |||||
| const reject = (route: Route, status: number, message: string) => route.fulfill({ | |||||
| status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-rejected' }), | |||||
| }) | |||||
| export class TeachingContractMock { | |||||
| identity: TeachingIdentity | |||||
| readonly requests: RecordedTeachingRequest[] = [] | |||||
| readonly assignments = new Map<string, JsonRecord>() | |||||
| readonly runs = new Map<string, JsonRecord>() | |||||
| readonly events = new Map<string, JsonRecord[]>() | |||||
| readonly interventions: JsonRecord[] = [] | |||||
| readonly deniedPermissions = new Set<string>() | |||||
| administrativeRunsRejected = 0 | |||||
| administrativeAnalyticsRecordsRejected = 0 | |||||
| constructor(identity: TeachingIdentity) { | |||||
| this.identity = identity | |||||
| const tasks = [ | |||||
| assignment('assignment-virtual-common', 'VIRTUAL', 'TRAINING', 'COMMON', 'INDIVIDUAL'), | |||||
| assignment('assignment-physical', 'PHYSICAL', 'TRAINING', 'ASSIGNED', 'INDIVIDUAL'), | |||||
| assignment('assignment-confrontation', 'CONFRONTATION', 'TRAINING', 'ASSIGNED', 'TEAM'), | |||||
| assignment('assignment-exam', 'VIRTUAL', 'FORMAL_EXAM', 'ASSIGNED', 'INDIVIDUAL'), | |||||
| assignment('assignment-exam-physical', 'PHYSICAL', 'FORMAL_EXAM', 'ASSIGNED', 'INDIVIDUAL'), | |||||
| assignment('assignment-exam-confrontation', 'CONFRONTATION', 'FORMAL_EXAM', 'ASSIGNED', 'TEAM'), | |||||
| ] | |||||
| tasks.forEach((task) => this.assignments.set(String(task.id), task)) | |||||
| this.runs.set('run-physical', runFor('run-physical', this.requireAssignment('assignment-physical'), 'ACCEPTED')) | |||||
| this.runs.set('run-confrontation', runFor('run-confrontation', this.requireAssignment('assignment-confrontation'), 'IN_PROGRESS')) | |||||
| this.runs.set('run-exam', runFor('run-exam', this.requireAssignment('assignment-exam'), 'IN_PROGRESS')) | |||||
| } | |||||
| setIdentity(identity: TeachingIdentity) { this.identity = identity } | |||||
| denyPermissions(...codes: string[]) { codes.forEach((code) => this.deniedPermissions.add(code)) } | |||||
| seedImmersiveRun() { | |||||
| const task = this.requireAssignment('assignment-virtual-common') | |||||
| const runtime = runFor('run-immersive', task, 'IN_PROGRESS') | |||||
| runtime.currentStepCode = 'STEP-01' | |||||
| this.runs.set(String(runtime.id), runtime) | |||||
| } | |||||
| seedVirtualReviewedRun() { | |||||
| const task = this.requireAssignment('assignment-virtual-common') | |||||
| const runtime = runFor('run-virtual-reviewed', task, 'REVIEWED') | |||||
| runtime.currentStepCode = 'STEP-02' | |||||
| runtime.progressPercent = 100 | |||||
| runtime.score = 92 | |||||
| runtime.passed = true | |||||
| runtime.reviewFeedback = '步骤完整,安全确认和检修复核符合要求。' | |||||
| runtime.checkpoint = { completedStepCodes: ['STEP-01', 'STEP-02'] } | |||||
| this.runs.set(String(runtime.id), runtime) | |||||
| this.events.set(String(runtime.id), [ | |||||
| { | |||||
| id: 'event-virtual-reviewed-1', code: 'EVENT-VIRTUAL-001', assignmentId: task.id, runId: runtime.id, | |||||
| targetType: 'RUN', targetId: runtime.id, type: 'training.action.completed', title: '完成:识别作业对象', | |||||
| summary: '确认训练对象与安全边界。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-01' }, | |||||
| actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387260, | |||||
| }, | |||||
| { | |||||
| id: 'event-virtual-reviewed-2', code: 'EVENT-VIRTUAL-002', assignmentId: task.id, runId: runtime.id, | |||||
| targetType: 'RUN', targetId: runtime.id, type: 'training.action.completed', title: '完成:完成检修操作', | |||||
| summary: '按流程完成检修并复核。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-02' }, | |||||
| actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387560, | |||||
| }, | |||||
| ]) | |||||
| return runtime | |||||
| } | |||||
| seedConfrontationMonitorRuns() { | |||||
| const task = this.requireAssignment('assignment-confrontation') | |||||
| const active = this.runs.get('run-confrontation')! | |||||
| active.status = 'IN_PROGRESS' | |||||
| active.subjectCode = 'RED' | |||||
| active.members = [teachingMembers.instructor, teachingMembers.leader, teachingMembers.operator] | |||||
| active.progressPercent = 50 | |||||
| active.currentStepCode = 'STEP-02' | |||||
| this.events.set(String(active.id), [{ | |||||
| id: 'event-confrontation-red-1', code: 'EVENT-CON-RED-001', assignmentId: task.id, runId: active.id, | |||||
| targetType: 'RUN', targetId: active.id, type: 'training.action.completed', title: '队长已下达处置指令', | |||||
| summary: '红方进入故障隔离阶段。', source: 'web', visibility: 'PUBLIC', payload: { completedStepCode: 'STEP-01' }, | |||||
| actorUserId: teachingMembers.leader.userId, actorDisplayName: teachingMembers.leader.displayName, occurredAt: 1786387320, | |||||
| }]) | |||||
| const blue = runFor('run-confrontation-blue', task, 'ACCEPTED', teachingMembers.commander) | |||||
| blue.subjectCode = 'BLUE' | |||||
| blue.members = [teachingMembers.instructor, teachingMembers.commander] | |||||
| blue.currentStepCode = 'STEP-01' | |||||
| blue.progressPercent = 0 | |||||
| this.runs.set(String(blue.id), blue) | |||||
| this.events.set(String(blue.id), []) | |||||
| return { active, blue } | |||||
| } | |||||
| seedExpiredRun() { | |||||
| const task = this.requireAssignment('assignment-virtual-common') | |||||
| task.dueAt = Number(task.serverNow) - 60 | |||||
| const runtime = runFor('run-timeout', task, 'IN_PROGRESS') | |||||
| runtime.startedAt = Number(task.serverNow) - 3_600 | |||||
| runtime.progressPercent = 50 | |||||
| this.runs.set(String(runtime.id), runtime) | |||||
| return runtime | |||||
| } | |||||
| seedTerminableRun() { | |||||
| const task = this.requireAssignment('assignment-virtual-common') | |||||
| task.dueAt = null | |||||
| const runtime = runFor('run-terminate', task, 'IN_PROGRESS') | |||||
| runtime.startedAt = Number(task.serverNow) - 600 | |||||
| runtime.progressPercent = 25 | |||||
| this.runs.set(String(runtime.id), runtime) | |||||
| return runtime | |||||
| } | |||||
| seedConfrontationTerminationCases() { | |||||
| const task = this.requireAssignment('assignment-confrontation') | |||||
| task.dueAt = null | |||||
| const active = this.runs.get('run-confrontation')! | |||||
| active.status = 'IN_PROGRESS' | |||||
| active.startedAt = Number(task.serverNow) - 21 * 60 | |||||
| active.progressPercent = 50 | |||||
| const accepted = runFor('run-confrontation-queued', task, 'ACCEPTED', teachingMembers.operator) | |||||
| this.runs.set(String(accepted.id), accepted) | |||||
| return { active, accepted } | |||||
| } | |||||
| keepOnlyRuns(...runIds: string[]) { | |||||
| const selected = runIds.map((id) => this.runs.get(id)).filter((item): item is JsonRecord => Boolean(item)) | |||||
| this.runs.clear() | |||||
| selected.forEach((item) => this.runs.set(String(item.id), item)) | |||||
| } | |||||
| count(method: string, path: string | RegExp) { | |||||
| return this.requests.filter((item) => item.method === method && (typeof path === 'string' ? item.path === path : path.test(item.path))).length | |||||
| } | |||||
| last(method: string, path: string | RegExp) { | |||||
| return [...this.requests].reverse().find((item) => item.method === method && (typeof path === 'string' ? item.path === path : path.test(item.path))) | |||||
| } | |||||
| private requireAssignment(id: string) { | |||||
| const item = this.assignments.get(id) | |||||
| if (!item) throw new Error(`测试任务不存在:${id}`) | |||||
| return item | |||||
| } | |||||
| private currentUserId() { | |||||
| if (this.identity === 'studentOperator') return '5' | |||||
| if (this.identity === 'studentLeader' || this.identity === 'unionAdministrativeStudent') return '4' | |||||
| if (this.identity === 'teacher') return '2' | |||||
| if (this.identity === 'administrative') return '3' | |||||
| return '1' | |||||
| } | |||||
| private profile() { | |||||
| const union = this.identity === 'unionAdministrativeStudent' | |||||
| const role = this.identity === 'teacher' ? 'teacher' | |||||
| : this.identity === 'administrative' || union ? 'administrative' | |||||
| : this.identity === 'studentLeader' || this.identity === 'studentOperator' ? 'student' : 'admin' | |||||
| const userId = this.currentUserId() | |||||
| const displayName = this.identity === 'studentOperator' ? '红方操作员' : this.identity === 'studentLeader' || union ? '红方队长' : roleNames[role] | |||||
| const roles = union | |||||
| ? [ | |||||
| { id: 'role-administrative', code: 'administrative', name: '行政', shortName: '行', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'DEPARTMENT' }, | |||||
| { id: 'role-student', code: 'student', name: '学员', shortName: '学', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'SELF' }, | |||||
| ] | |||||
| : [{ id: `role-${role}`, code: role, name: roleNames[role], shortName: roleNames[role].slice(0, 1), status: 1, builtIn: 1, isSuperAdmin: role === 'admin' ? 1 : 0, dataScopeCode: role === 'admin' ? 'ALL' : role === 'student' ? 'SELF' : 'DEPARTMENT' }] | |||||
| return { | |||||
| user: { id: userId, username: this.identity, displayName, departmentId: '110', departmentName: '维修教研室', mustChangePassword: false, version: 1 }, | |||||
| activeRoleId: union ? 'role-administrative' : `role-${role}`, roles, | |||||
| permissions: permissions[this.identity].filter((permission) => !this.deniedPermissions.has(permission)), authorizationMode: union ? 'UNION' : 'SINGLE_ACTIVE', loginTime: 1786387200, | |||||
| } | |||||
| } | |||||
| private currentMember(run: JsonRecord) { | |||||
| const values = Array.isArray(run.members) ? run.members as JsonRecord[] : [] | |||||
| return values.find((item) => item.userId === this.currentUserId() && item.memberType === 'LEARNER') | |||||
| } | |||||
| private visibleRuns() { | |||||
| const values = [...this.runs.values()] | |||||
| if (this.identity === 'teacher' || this.identity === 'admin') return values | |||||
| const userId = this.currentUserId() | |||||
| return values.filter((item) => { | |||||
| const values = Array.isArray(item.members) ? item.members as JsonRecord[] : [] | |||||
| return values.some((member) => member.userId === userId && member.memberType === 'LEARNER') | |||||
| }) | |||||
| } | |||||
| private stepsFor(run: JsonRecord) { | |||||
| const task = this.requireAssignment(String(run.assignmentId)) | |||||
| const definition = task.definitionSnapshot as JsonRecord | |||||
| return (definition.steps as JsonRecord[]) ?? [] | |||||
| } | |||||
| private advanceRun(route: Route, run: JsonRecord, completedStepCode: string, actionCode?: string, respond = true) { | |||||
| const steps = this.stepsFor(run) | |||||
| const currentCode = String(run.currentStepCode) | |||||
| if (completedStepCode !== currentCode) return reject(route, 409, `完成步骤必须等于当前待办步骤 ${currentCode}`) | |||||
| const index = steps.findIndex((step) => stepCodeOf(step) === completedStepCode) | |||||
| if (index < 0) return reject(route, 400, '步骤不属于发布任务') | |||||
| const task = this.requireAssignment(String(run.assignmentId)) | |||||
| if (task.channel === 'CONFRONTATION') { | |||||
| const current = this.currentMember(run) | |||||
| const allowed = (steps[index]!.allowedPositions ?? steps[index]!.requiredPositions ?? []) as string[] | |||||
| if (!current || !allowed.includes(String(current.positionCode))) return reject(route, 403, '当前岗位不能执行此动作') | |||||
| if (actionCode !== steps[index]!.actionCode) return reject(route, 400, 'actionCode 与当前步骤不匹配') | |||||
| } | |||||
| const next = steps[Math.min(index + 1, steps.length - 1)]! | |||||
| run.progressPercent = Math.round(((index + 1) * 100) / steps.length) | |||||
| run.currentStepCode = stepCodeOf(next) | |||||
| run.version = Number(run.version) + 1 | |||||
| const events = this.events.get(String(run.id)) ?? [] | |||||
| events.push({ | |||||
| id: `event-${events.length + 1}`, code: `EVENT-${events.length + 1}`, assignmentId: run.assignmentId, runId: run.id, | |||||
| targetType: 'RUN', targetId: run.id, type: 'training.action.completed', title: `完成:${steps[index]!.title}`, | |||||
| summary: steps[index]!.publicSummary, source: 'web', visibility: 'PUBLIC', payload: { completedStepCode, actionCode }, | |||||
| actorUserId: this.currentUserId(), actorDisplayName: this.profile().user.displayName, occurredAt: 1786387200 + events.length, | |||||
| }) | |||||
| this.events.set(String(run.id), events) | |||||
| return respond ? fulfill(route, run, 'ute2e-progress-authoritative') : Promise.resolve() | |||||
| } | |||||
| async install(page: Page) { | |||||
| await page.addInitScript(() => sessionStorage.setItem('unreal-tran:web:access-token:v1', 'teaching-contract-token')) | |||||
| await page.route('**/api/auth/v1/auth/me', (route) => fulfill(route, this.profile(), 'ute2e-teaching-me')) | |||||
| await page.route('**/api/auth/v1/menus/navigation', (route) => fulfill(route, [], 'ute2e-teaching-menu')) | |||||
| await page.route('**/api/auth/v1/directory/teaching-members**', (route) => fulfill(route, [ | |||||
| { id: '4', username: 'student.leader', displayName: '红方队长', departmentId: '110', departmentName: '维修教研室', roleCodes: ['student'] }, | |||||
| { id: '5', username: 'student.operator', displayName: '红方操作员', departmentId: '110', departmentName: '维修教研室', roleCodes: ['student'] }, | |||||
| ], 'ute2e-teaching-directory')) | |||||
| await page.route('**/api/tran/v1/content/catalog**', (route) => fulfill(route, pageResult([{ | |||||
| projectId: 'training-project-1', versionId: 'training-version-1', type: 'TRAINING', code: 'TRAIN-001', name: '挖掘机液压系统训练', categoryCode: 'VIRTUAL', coverUri: '', versionNumber: 2, versionStatus: 'PUBLISHED', publishedAt: '2026-08-11T08:00:00+08:00', content: { steps: [] }, | |||||
| }]), 'ute2e-teaching-catalog')) | |||||
| await page.route('**/api/tran/v1/teaching/**', (route) => this.handleTeaching(route)) | |||||
| } | |||||
| private async handleTeaching(route: Route) { | |||||
| const request = route.request() | |||||
| const url = new URL(request.url()) | |||||
| const path = url.pathname.replace(/^.*\/teaching/, '') | |||||
| const method = request.method() | |||||
| const body = bodyOf(route) | |||||
| this.requests.push({ method, path, query: new URLSearchParams(url.searchParams), body }) | |||||
| if (method === 'GET' && path === '/guides') return fulfill(route, pageResult([{ | |||||
| projectId: 'guide-project-1', versionId: 'guide-version-1', code: 'GUIDE-001', name: '挖掘机检修作业指导书', description: '标准维修作业流程', categoryCode: '维修作业', coverUri: '', equipment: '履带式挖掘机', specification: '教学训练型', chapterCount: 2, stepCount: 3, versionNumber: 3, versionCode: 'V3', publishedAt: 1786387200, | |||||
| }]), 'ute2e-guides') | |||||
| if (method === 'GET' && path === '/guides/guide-project-1') return fulfill(route, { | |||||
| projectId: 'guide-project-1', versionId: 'guide-version-1', code: 'GUIDE-001', name: '挖掘机检修作业指导书', description: '标准维修作业流程', categoryCode: '维修作业', coverUri: '', versionNumber: 3, versionCode: 'V3', publishedAt: 1786387200, | |||||
| content: { | |||||
| equipment: '履带式挖掘机', specification: '教学训练型', | |||||
| chapters: [ | |||||
| { | |||||
| id: 'chapter-1', title: '安全准备', order: 1, | |||||
| steps: [ | |||||
| { id: 'step-1', title: '断电隔离', summary: '确认停机、断电并完成安全隔离。', safety: '执行挂牌上锁。', acceptance: '设备处于零能量状态。', duration: '5分钟', type: 'CHECK', tools: '安全锁具', parameters: {}, media: '', part: '动力总成', anchor: '', animation: '', order: 1 }, | |||||
| { id: 'step-2', title: '压力释放', summary: '缓慢释放液压系统残余压力。', safety: '佩戴护目镜并确认卸压方向无人。', acceptance: '压力表回零且系统无残压。', duration: '8分钟', type: 'SAFETY', tools: '压力表', parameters: { '目标压力': '0 MPa' }, media: '', part: '液压回路', anchor: '', animation: '', order: 2 }, | |||||
| ], | |||||
| }, | |||||
| { | |||||
| id: 'chapter-2', title: '检修复核', order: 2, | |||||
| steps: [{ id: 'step-3', title: '复装与复测', summary: '完成部件复装并按标准执行功能复测。', safety: '清点工具并恢复防护装置。', acceptance: '动作平稳、无泄漏且记录完整。', duration: '12分钟', type: 'VERIFY', tools: '扭矩扳手', parameters: { '复测次数': '2 次' }, media: '', part: '执行机构', anchor: '', animation: '', order: 1 }], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| }, 'ute2e-guide-detail') | |||||
| if (method === 'GET' && path === '/assignments/summary') { | |||||
| const channel = url.searchParams.get('channel') | |||||
| const kind = url.searchParams.get('assignmentKind') ?? 'TRAINING' | |||||
| const tasks = [...this.assignments.values()].filter((item) => (!channel || item.channel === channel) && item.assignmentKind === kind) | |||||
| return fulfill(route, { assignments: tasks.length, draft: 0, published: tasks.length, withdrawn: 0, archived: 0, pendingRuns: 0, activeRuns: this.visibleRuns().filter((item) => item.status === 'IN_PROGRESS').length, submittedRuns: this.visibleRuns().filter((item) => item.status === 'SUBMITTED').length, reviewedRuns: this.visibleRuns().filter((item) => item.status === 'REVIEWED').length, byChannel: Object.fromEntries(tasks.map((item) => [String(item.channel), 1])) }, 'ute2e-summary') | |||||
| } | |||||
| if (method === 'GET' && path === '/assignments') { | |||||
| const channel = url.searchParams.get('channel') | |||||
| const kind = url.searchParams.get('assignmentKind') ?? 'TRAINING' | |||||
| const tasks = [...this.assignments.values()].filter((item) => (!channel || item.channel === channel) && item.assignmentKind === kind) | |||||
| return fulfill(route, pageResult(tasks), 'ute2e-assignments') | |||||
| } | |||||
| if (method === 'POST' && path === '/assignments') { | |||||
| if (typeof body.scheduleStartAt !== 'number' || typeof body.dueAt !== 'number' || body.scheduleStartAt >= 1_000_000_000_000 || body.dueAt >= 1_000_000_000_000) return reject(route, 400, '正式考核时间必须是 Unix 秒') | |||||
| if (body.assignmentKind === 'FORMAL_EXAM' && (body.executionMode !== 'EXAM' || body.digitalHumanAllowed !== false)) return reject(route, 400, '正式考核必须使用 EXAM 且禁用数字教员') | |||||
| const created = { ...assignment('assignment-created-exam', String(body.channel) as Channel, String(body.assignmentKind) as AssignmentKind, 'ASSIGNED', 'INDIVIDUAL'), ...body, id: 'assignment-created-exam', version: 0, status: 'DRAFT' } | |||||
| this.assignments.set(String(created.id), created) | |||||
| return fulfill(route, created, 'ute2e-assignment-created') | |||||
| } | |||||
| const assignmentAssetMatch = path.match(/^\/assignments\/([^/]+)\/assets$/) | |||||
| if (method === 'GET' && assignmentAssetMatch) return fulfill(route, this.assetList(), 'ute2e-assignment-assets') | |||||
| const assignmentFaultMatch = path.match(/^\/assignments\/([^/]+)\/faults$/) | |||||
| if (method === 'GET' && assignmentFaultMatch) { | |||||
| if (this.identity === 'administrative') return reject(route, 403, '行政身份不可查看故障细节') | |||||
| return fulfill(route, [{ | |||||
| id: 'assignment-fault-public', assignmentId: assignmentFaultMatch[1], faultId: this.identity === 'teacher' || this.identity === 'admin' ? 'fault-1' : null, | |||||
| faultCode: this.identity === 'teacher' || this.identity === 'admin' ? 'HYDRAULIC.VALVE.STUCK' : '', | |||||
| faultName: this.identity === 'teacher' || this.identity === 'admin' ? '液压阀卡滞' : '', severity: this.identity === 'teacher' || this.identity === 'admin' ? 'HIGH' : '', | |||||
| publicSymptom: '动臂响应迟缓且压力波动', privateTruth: this.identity === 'teacher' || this.identity === 'admin' ? { text: '液压阀芯卡滞真值' } : null, | |||||
| triggerConfig: this.identity === 'teacher' || this.identity === 'admin' ? { hidden: '内部触发条件' } : null, | |||||
| targetScope: 'TEAM', targetTeamCode: 'RED', targetGroupCode: 'GROUP-RED', sortOrder: 0, | |||||
| }], 'ute2e-assignment-faults') | |||||
| } | |||||
| const interventionListMatch = path.match(/^\/assignments\/([^/]+)\/interventions$/) | |||||
| if (interventionListMatch && method === 'GET') return fulfill(route, this.interventions, 'ute2e-interventions') | |||||
| if (interventionListMatch && method === 'POST') { | |||||
| if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无教学干预权限') | |||||
| if (!['ASSIGNMENT', 'TEAM', 'MEMBER'].includes(String(body.targetScope))) return reject(route, 400, '干预作用范围不符合契约') | |||||
| if (body.targetScope === 'TEAM' && !['RED', 'BLUE', 'NEUTRAL'].includes(String(body.targetTeamCode))) return reject(route, 400, 'TEAM 干预必须携带目标队伍') | |||||
| if (body.targetScope === 'MEMBER' && !body.targetMemberId) return reject(route, 400, 'MEMBER 干预必须携带目标成员') | |||||
| if (typeof body.publicSummary !== 'string' || !body.publicSummary.trim()) return reject(route, 400, '干预必须提供公开说明') | |||||
| if (/privateTruth|triggerConfig|solution|answer|truth/i.test(JSON.stringify(body.publicPayload ?? {}))) return reject(route, 400, '公开干预载荷不能包含故障真值或解决方案') | |||||
| const privatePayload = body.privatePayload ?? {} | |||||
| const payload = Object.keys(privatePayload as JsonRecord).length | |||||
| ? { public: body.publicPayload ?? {}, private: privatePayload } | |||||
| : body.publicPayload ?? {} | |||||
| const item = { | |||||
| id: `intervention-${this.interventions.length + 1}`, assignmentId: interventionListMatch[1], | |||||
| code: body.code, type: body.type, targetScope: body.targetScope, | |||||
| targetMemberId: body.targetScope === 'MEMBER' ? body.targetMemberId : '', | |||||
| targetTeamCode: body.targetScope === 'TEAM' ? body.targetTeamCode : '', | |||||
| targetGroupCode: body.targetScope === 'TEAM' ? body.targetGroupCode ?? '' : '', | |||||
| publicSummary: body.publicSummary, payload, status: 'DRAFT', createdByUserId: '2', createdByName: '周教员', activatedAt: null, revokedAt: null, version: 0, | |||||
| } | |||||
| this.interventions.push(item) | |||||
| return fulfill(route, item, 'ute2e-intervention-created') | |||||
| } | |||||
| const assignmentDetailMatch = path.match(/^\/assignments\/([^/]+)$/) | |||||
| if (method === 'GET' && assignmentDetailMatch) { | |||||
| const item = this.requireAssignment(assignmentDetailMatch[1]!) | |||||
| const learnerProjection = !['admin', 'teacher'].includes(this.identity) && item.assignmentKind === 'FORMAL_EXAM' | |||||
| ? { ...item, definitionSnapshot: null } | |||||
| : item | |||||
| return fulfill(route, learnerProjection, 'ute2e-assignment-detail') | |||||
| } | |||||
| const acceptMatch = path.match(/^\/assignments\/([^/]+)\/accept$/) | |||||
| if (method === 'POST' && acceptMatch) { | |||||
| const task = this.requireAssignment(acceptMatch[1]!) | |||||
| if (body.assignmentVersion !== task.version || body.teamCode !== 'NEUTRAL' || body.groupCode !== 'INDIVIDUAL' || body.positionCode !== 'LEARNER') return reject(route, 400, '领取 DTO 不符合契约') | |||||
| const pending = [...this.runs.values()].find((item) => item.assignmentId === task.id && item.status === 'PENDING') | |||||
| if (pending) { | |||||
| pending.status = 'ACCEPTED' | |||||
| pending.acceptedAt = 1786386000 | |||||
| pending.version = Number(pending.version) + 1 | |||||
| return fulfill(route, pending, 'ute2e-assigned-task-accepted') | |||||
| } | |||||
| const created = runFor('run-virtual-common', task, 'ACCEPTED', teachingMembers.leader) | |||||
| this.runs.set(String(created.id), created) | |||||
| return fulfill(route, created, 'ute2e-assignment-accepted') | |||||
| } | |||||
| if (method === 'GET' && path === '/runs') { | |||||
| if (this.identity === 'administrative') { this.administrativeRunsRejected += 1; return reject(route, 403, '行政身份不可读取运行明细') } | |||||
| const assignmentId = url.searchParams.get('assignmentId') | |||||
| const channel = url.searchParams.get('channel') | |||||
| const kind = url.searchParams.get('assignmentKind') | |||||
| const values = this.visibleRuns().filter((item) => (!assignmentId || item.assignmentId === assignmentId) && (!channel || (item.assignment as JsonRecord).channel === channel) && (!kind || (item.assignment as JsonRecord).assignmentKind === kind)) | |||||
| return fulfill(route, pageResult(values), 'ute2e-runs') | |||||
| } | |||||
| const runAssetsMatch = path.match(/^\/runs\/([^/]+)\/assets$/) | |||||
| if (method === 'GET' && runAssetsMatch) return fulfill(route, this.assetList(), 'ute2e-run-assets') | |||||
| const runExecutionMatch = path.match(/^\/runs\/([^/]+)\/execution$/) | |||||
| if (method === 'GET' && runExecutionMatch) { | |||||
| const item = this.runs.get(runExecutionMatch[1]!) | |||||
| if (!item) return reject(route, 404, '运行不存在') | |||||
| const task = this.requireAssignment(String(item.assignmentId)) | |||||
| if (task.assignmentKind !== 'FORMAL_EXAM' || item.status !== 'IN_PROGRESS') return reject(route, 409, '仅正式考核进行中运行提供执行投影') | |||||
| const definition = task.definitionSnapshot as JsonRecord | |||||
| const steps = (definition.steps as JsonRecord[]) ?? [] | |||||
| const index = Math.max(0, steps.findIndex((step) => stepCodeOf(step) === item.currentStepCode)) | |||||
| return fulfill(route, { | |||||
| runId: item.id, | |||||
| assignmentId: item.assignmentId, | |||||
| currentStepCode: item.currentStepCode, | |||||
| currentStepIndex: index, | |||||
| totalSteps: steps.length, | |||||
| progressPercent: item.progressPercent, | |||||
| currentStep: steps[index] ?? null, | |||||
| resources: definition.resources ?? [], | |||||
| scenario: definition.scenario ?? {}, | |||||
| tools: [], | |||||
| runtime: { showStepPanel: false, allowReplay: false, allowRollback: false, digitalHumanAllowed: false }, | |||||
| modeConfig: definition.modeConfig ?? {}, | |||||
| examPolicy: { hintsAllowed: false, demoAllowed: false, replayAllowed: false, rollbackAllowed: false, digitalHumanAllowed: false }, | |||||
| deadlineAt: task.dueAt, | |||||
| serverNow: task.serverNow, | |||||
| }, 'ute2e-run-execution') | |||||
| } | |||||
| const runEventsMatch = path.match(/^\/runs\/([^/]+)\/events$/) | |||||
| if (method === 'GET' && runEventsMatch) return fulfill(route, this.events.get(runEventsMatch[1]!) ?? [], 'ute2e-events') | |||||
| const runDetailMatch = path.match(/^\/runs\/([^/]+)$/) | |||||
| if (method === 'GET' && runDetailMatch) { | |||||
| const item = this.runs.get(runDetailMatch[1]!) | |||||
| return item ? fulfill(route, item, 'ute2e-run-detail') : reject(route, 404, '运行不存在') | |||||
| } | |||||
| const startMatch = path.match(/^\/runs\/([^/]+)\/start$/) | |||||
| if (method === 'POST' && startMatch) { | |||||
| const item = this.runs.get(startMatch[1]!) | |||||
| if (!item || item.status !== 'ACCEPTED' || body.version !== item.version) return reject(route, 409, '运行版本或状态冲突') | |||||
| item.status = 'IN_PROGRESS'; item.startedAt = 1786387200; item.version = Number(item.version) + 1 | |||||
| return fulfill(route, item, 'ute2e-run-started') | |||||
| } | |||||
| const progressMatch = path.match(/^\/runs\/([^/]+)\/progress$/) | |||||
| if (method === 'PUT' && progressMatch) { | |||||
| const item = this.runs.get(progressMatch[1]!) | |||||
| if (!item || item.status !== 'IN_PROGRESS' || body.version !== item.version) return reject(route, 409, '运行版本或状态冲突') | |||||
| if ('percent' in body || 'currentStepCode' in body) return reject(route, 400, '进度与下一步骤必须由服务端派生') | |||||
| if (typeof body.completedStepCode !== 'string') return reject(route, 400, '缺少 completedStepCode') | |||||
| return this.advanceRun(route, item, body.completedStepCode, typeof body.actionCode === 'string' ? body.actionCode : undefined) | |||||
| } | |||||
| const physicalMatch = path.match(/^\/runs\/([^/]+)\/physical-events$/) | |||||
| if (method === 'POST' && physicalMatch) { | |||||
| const item = this.runs.get(physicalMatch[1]!) | |||||
| if (!item || item.status !== 'IN_PROGRESS' || body.runVersion !== item.version) return reject(route, 409, '实装运行版本或状态冲突') | |||||
| if (body.source !== 'MANUAL' || body.manualConfirmed !== true || typeof body.eventId !== 'string' || body.stepCode !== item.currentStepCode) return reject(route, 400, '实装事件 DTO 不符合契约') | |||||
| if (typeof body.occurredAt !== 'number' || body.occurredAt >= 1_000_000_000_000) return reject(route, 400, '现场事件时间必须是 Unix 秒') | |||||
| const currentStep = this.stepsFor(item).find((step) => stepCodeOf(step) === body.stepCode) | |||||
| const execution = currentStep?.physicalExecution as JsonRecord | undefined | |||||
| if (!execution || execution.source !== body.source || execution.eventType !== body.eventType || execution.manualConfirmAllowed === false) return reject(route, 400, '现场事件与当前步骤执行规则不匹配') | |||||
| await this.advanceRun(route, item, String(body.stepCode), undefined, false) | |||||
| return fulfill(route, { id: `physical-${this.count('POST', /physical-events$/)}`, eventId: body.eventId, assignmentId: item.assignmentId, runId: item.id, source: body.source, eventType: body.eventType, stepCode: body.stepCode, outcome: 'MATCHED', matchedRule: true, progressApplied: true, occurredAt: body.occurredAt, runVersion: item.version }, 'ute2e-physical-event') | |||||
| } | |||||
| const submitMatch = path.match(/^\/runs\/([^/]+)\/submit$/) | |||||
| if (method === 'POST' && submitMatch) { | |||||
| const item = this.runs.get(submitMatch[1]!) | |||||
| if (!item || item.status !== 'IN_PROGRESS' || item.progressPercent !== 100 || body.version !== item.version) return reject(route, 409, '运行未完成或版本冲突') | |||||
| const task = this.requireAssignment(String(item.assignmentId)) | |||||
| if (task.collaborationMode === 'TEAM') { | |||||
| const current = this.currentMember(item) | |||||
| if (!current || !['TEAM_LEADER', 'COMMANDER'].includes(String(current.positionCode))) return reject(route, 403, '共享运行仅负责人可提交') | |||||
| } | |||||
| item.status = 'SUBMITTED'; item.submittedAt = 1786390800; item.submission = { summary: body.summary }; item.version = Number(item.version) + 1 | |||||
| return fulfill(route, item, 'ute2e-run-submitted') | |||||
| } | |||||
| const timeoutMatch = path.match(/^\/runs\/([^/]+)\/timeout$/) | |||||
| if (method === 'POST' && timeoutMatch) { | |||||
| const item = this.runs.get(timeoutMatch[1]!) | |||||
| if (!item || !['PENDING', 'ACCEPTED', 'IN_PROGRESS'].includes(String(item.status)) || body.version !== item.version) return reject(route, 409, '超时结算运行版本或状态冲突') | |||||
| if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无评定权限') | |||||
| const task = this.requireAssignment(String(item.assignmentId)) | |||||
| const now = Number(task.serverNow) | |||||
| const definition = task.definitionSnapshot as JsonRecord | |||||
| const modeConfig = (definition.modeConfig ?? {}) as JsonRecord | |||||
| const roundSeconds = Number(modeConfig.durationSeconds ?? 0) | |||||
| || Number(modeConfig.roundDurationMinutes ?? modeConfig.roundDuration ?? 0) * 60 | |||||
| const dueExpired = typeof task.dueAt === 'number' && now >= task.dueAt | |||||
| const roundExpired = task.channel === 'CONFRONTATION' && typeof item.startedAt === 'number' && roundSeconds > 0 && now >= item.startedAt + roundSeconds | |||||
| if (!dueExpired && !roundExpired) return reject(route, 409, '运行尚未超时') | |||||
| item.status = 'SUBMITTED' | |||||
| item.submission = { summary: '运行已由服务端执行超时结算。', timeout: true } | |||||
| item.submittedAt = now | |||||
| item.version = Number(item.version) + 1 | |||||
| return fulfill(route, item, 'ute2e-run-timeout-submitted') | |||||
| } | |||||
| const terminateMatch = path.match(/^\/runs\/([^/]+)\/terminate$/) | |||||
| if (method === 'POST' && terminateMatch) { | |||||
| const item = this.runs.get(terminateMatch[1]!) | |||||
| if (!item || !['PENDING', 'ACCEPTED', 'IN_PROGRESS'].includes(String(item.status)) || body.version !== item.version) return reject(route, 409, '结束运行版本或状态冲突') | |||||
| if (!['teacher', 'admin'].includes(this.identity)) return reject(route, 403, '当前身份无评定权限') | |||||
| const task = this.requireAssignment(String(item.assignmentId)) | |||||
| if (task.assignmentKind !== 'TRAINING' || task.dueAt != null || (task.channel === 'CONFRONTATION' && item.status === 'IN_PROGRESS')) return reject(route, 409, '当前运行应使用超时结算') | |||||
| if (typeof body.reason !== 'string' || !body.reason.trim() || body.reason.length > 500) return reject(route, 400, '结束运行必须提供 1 至 500 字原因') | |||||
| item.status = 'SUBMITTED' | |||||
| item.submission = { summary: `运行由教员结束:${body.reason.trim()}`, terminated: true } | |||||
| item.submittedAt = Number(task.serverNow) | |||||
| item.version = Number(item.version) + 1 | |||||
| return fulfill(route, item, 'ute2e-run-terminated-submitted') | |||||
| } | |||||
| const reviewMatch = path.match(/^\/runs\/([^/]+)\/review$/) | |||||
| if (method === 'POST' && reviewMatch) { | |||||
| const item = this.runs.get(reviewMatch[1]!) | |||||
| if (!item || item.status !== 'SUBMITTED' || body.version !== item.version || typeof body.score !== 'number') return reject(route, 409, '评定 DTO 或状态冲突') | |||||
| item.status = 'REVIEWED'; item.score = body.score; item.passed = body.passed; item.reviewFeedback = body.feedback; item.reviewerName = '周教员'; item.reviewedAt = 1786394400; item.version = Number(item.version) + 1 | |||||
| return fulfill(route, item, 'ute2e-run-reviewed') | |||||
| } | |||||
| const activateMatch = path.match(/^\/interventions\/([^/]+)\/activate$/) | |||||
| if (method === 'POST' && activateMatch) { | |||||
| const item = this.interventions.find((value) => value.id === activateMatch[1]) | |||||
| if (!item || body.version !== item.version) return reject(route, 409, '干预版本冲突') | |||||
| item.status = 'ACTIVE'; item.activatedAt = 1786387200; item.version = Number(item.version) + 1 | |||||
| return fulfill(route, item, 'ute2e-intervention-active') | |||||
| } | |||||
| if (method === 'GET' && path === '/faults') return fulfill(route, pageResult([]), 'ute2e-faults') | |||||
| if (method === 'GET' && path === '/analytics') return fulfill(route, { assignmentCount: 6, participantCount: 12, runCount: 10, completedRunCount: 8, pendingReviewCount: 1, averageScore: 86.5, assignmentsByChannel: { VIRTUAL: 3, PHYSICAL: 2, CONFRONTATION: 1 }, runsByStatus: { ACCEPTED: 1, IN_PROGRESS: 1, REVIEWED: 8 }, membersByRole: { TEAM_LEADER: 3, OPERATOR: 5, COMMANDER: 2 } }, 'ute2e-analytics') | |||||
| if (method === 'GET' && path === '/analytics/records') { | |||||
| if (this.identity === 'administrative') { | |||||
| this.administrativeAnalyticsRecordsRejected += 1 | |||||
| return reject(route, 403, '行政身份仅可读取部门聚合,不可读取个人运行明细') | |||||
| } | |||||
| if (this.identity === 'unionAdministrativeStudent' && url.searchParams.get('userId') !== this.currentUserId()) { | |||||
| return reject(route, 403, '受限学员只能读取本人训练明细') | |||||
| } | |||||
| return fulfill(route, pageResult([{ | |||||
| runId: 'run-reviewed-analytics', runCode: 'RUN-ANALYTICS-001', assignmentId: 'assignment-exam', assignmentCode: 'EXAM-2026-001', assignmentName: '虚拟维修正式考核', assignmentKind: 'FORMAL_EXAM', channel: 'VIRTUAL', executionMode: 'EXAM', userId: '4', username: 'student.leader', displayName: '红方队长', departmentId: '110', departmentName: '维修教研室', status: 'REVIEWED', progressPercent: 100, score: 88, passed: true, acceptedAt: 1786386000, startedAt: 1786387200, submittedAt: 1786390800, reviewedAt: 1786394400, reviewerName: '周教员', serverNow: 1786394400, | |||||
| }]), 'ute2e-analytics-records') | |||||
| } | |||||
| if (method === 'GET' && path === '/xr/capabilities') return fulfill(route, { framework: 'three@0.185.0 + WebXR', sessionModes: ['INLINE', 'IMMERSIVE_VR'], referenceSpaces: ['LOCAL', 'LOCAL_FLOOR'], secureContextRequired: true, scoreAcceptedFromClient: false, resultPolicy: 'SERVER_AUTHORITATIVE', serverTimeSource: 'KINGBASE' }, 'ute2e-xr-capabilities') | |||||
| if (method === 'GET' && path === '/adapters') return fulfill(route, [{ id: 'adapter-dh', code: 'LOCAL_DIGITAL_HUMAN', name: '本地数字教员适配器', provider: 'LOCAL', mode: 'RESERVED', status: 'DISABLED', health: 'UNKNOWN', configSummary: {}, lastHealthAt: null, version: 1 }], 'ute2e-adapters') | |||||
| const xrStartMatch = path.match(/^\/runs\/([^/]+)\/xr-sessions$/) | |||||
| if (method === 'POST' && xrStartMatch) { | |||||
| const item = this.runs.get(xrStartMatch[1]!) | |||||
| if (!item || body.runVersion !== item.version || body.mode !== 'INLINE') return reject(route, 400, 'XR 会话 DTO 不符合契约') | |||||
| return fulfill(route, { id: 'xr-session-1', code: 'XR-SESSION-001', assignmentId: item.assignmentId, runId: item.id, userId: this.currentUserId(), mode: 'INLINE', referenceSpaceType: 'LOCAL', deviceName: body.deviceName, userAgentSummary: body.userAgentSummary, capabilities: body.capabilities, stateSnapshot: {}, status: 'ACTIVE', startedAt: 1786387200, lastHeartbeatAt: 1786387200, endedAt: null, failureReason: '', version: 0 }, 'ute2e-xr-session') | |||||
| } | |||||
| const xrEndMatch = path.match(/^\/xr-sessions\/([^/]+)\/end$/) | |||||
| if (method === 'PUT' && xrEndMatch) return fulfill(route, { id: xrEndMatch[1], code: 'XR-SESSION-001', assignmentId: 'assignment-virtual-common', runId: 'run-immersive', userId: this.currentUserId(), mode: 'INLINE', referenceSpaceType: 'LOCAL', deviceName: '桌面浏览器', userAgentSummary: '', capabilities: {}, stateSnapshot: {}, status: body.status, startedAt: 1786387200, lastHeartbeatAt: 1786387200, endedAt: 1786387260, failureReason: body.failureReason, version: 1 }, 'ute2e-xr-ended') | |||||
| return reject(route, 404, `未模拟教学端点 ${method} ${path}`) | |||||
| } | |||||
| private assetList() { | |||||
| return [{ | |||||
| sourceProjectId: 'asset-project-1', sourceVersionId: 'asset-version-1', assetCode: 'EXCAVATOR_A', | |||||
| name: 'excavator-a.glb', type: 'MODEL_FILE', contentUri: 'content://sha256/contract-excavator', | |||||
| mimeType: 'model/gltf-binary', sizeBytes: 11919032, sha256: 'contract-sha256', downloadable: true, | |||||
| downloadUrl: '/models/excavator-a.glb', | |||||
| }] | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,62 @@ | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const liveAccessToken = process.env.E2E_LIVE_ACCESS_TOKEN?.trim() ?? '' | |||||
| test.use({ trace: 'off', video: 'off' }) | |||||
| test.describe('教学实施真实环境只读联调', () => { | |||||
| test.skip(!liveAccessToken, '未配置 E2E_LIVE_ACCESS_TOKEN,跳过真实 API / KingBase 只读联调') | |||||
| test.beforeEach(async ({ page }) => { | |||||
| await page.addInitScript((accessToken) => { | |||||
| localStorage.removeItem('unreal-tran:web:access-token:v1') | |||||
| localStorage.removeItem('unreal-tran:web:refresh-token:v1') | |||||
| localStorage.removeItem('unreal-tran:web:remember:v1') | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken) | |||||
| }, liveAccessToken) | |||||
| }) | |||||
| test('六个教学入口读取真实 UAT 保留数据并生成验收截图', async ({ page }, testInfo) => { | |||||
| const visitTaskCenter = async (path: string, heading: string, uatCode: string, screenshotName: string) => { | |||||
| await page.goto(path) | |||||
| await expect(page).toHaveURL(new RegExp(`${path.replaceAll('/', '\\/')}$`)) | |||||
| await expect(page.getByRole('heading', { name: heading, exact: true })).toBeVisible() | |||||
| await expect(page.locator('.ttc-assignment-card').first()).toBeVisible() | |||||
| await expect(page.getByText(uatCode, { exact: false }).first()).toBeVisible() | |||||
| await expect.poll(async () => page.locator('.ttc-assignment-card img').first().evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true) | |||||
| await expect(page.locator('iframe')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, screenshotName) | |||||
| } | |||||
| await visitTaskCenter('/teaching/virtual-training', '虚拟仿真任务管理', 'UAT-TEACH-VIRTUAL-001', '真实环境-01-虚拟仿真') | |||||
| await visitTaskCenter('/teaching/physical-training', '实装实训任务管理', 'UAT-TEACH-PHYSICAL-001', '真实环境-02-实装实训') | |||||
| await visitTaskCenter('/teaching/confrontation', '对抗训练任务管理', 'UAT-TEACH-CONFRONTATION-001', '真实环境-03-对抗训练') | |||||
| await page.goto('/teaching/immersive') | |||||
| await expect(page.getByRole('heading', { name: '沉浸交互与外设联调', exact: true })).toBeVisible() | |||||
| await expect(page.getByTestId('immersive-device-panel')).toBeVisible() | |||||
| await expect(page.getByTestId('immersive-viewer-panel')).toBeVisible() | |||||
| await expect(page.locator('.immersive-viewer canvas')).toBeVisible({ timeout: 30_000 }) | |||||
| const previewSelector = page.locator('.xr-v2-selection-row .el-select').first() | |||||
| await previewSelector.click() | |||||
| await expect(page.locator('.el-select-dropdown:visible').getByText(/UAT-TEACH-VIRTUAL-00[12]/).first()).toBeVisible() | |||||
| await page.keyboard.press('Escape') | |||||
| await page.locator('.route-view').evaluate((element) => element.scrollTo({ top: 0 })) | |||||
| await expect(page.locator('iframe')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, '真实环境-04-沉浸交互') | |||||
| await visitTaskCenter('/teaching/exams', '考试测评', 'UAT-EXAM-VIRTUAL-001', '真实环境-05-考试测评') | |||||
| await page.goto('/teaching/data-center') | |||||
| await expect(page.getByRole('heading', { name: '教学数据中心', exact: true })).toBeVisible() | |||||
| await expect(page.getByTestId('teaching-dashboard-metrics')).toBeVisible() | |||||
| await expect(page.getByTestId('analytics-channel-summary')).toBeVisible() | |||||
| await expect(page.getByTestId('analytics-learner-table')).toBeVisible() | |||||
| await expect(page.getByTestId('analytics-learner-table').locator('tbody tr')).not.toHaveCount(0) | |||||
| await page.getByTestId('analytics-tab-training').click() | |||||
| await expect(page.getByText('UAT-TEACH-VIRTUAL-001', { exact: false }).first()).toBeVisible() | |||||
| await page.locator('.route-view').evaluate((element) => element.scrollTo({ top: 0 })) | |||||
| await expect(page.locator('iframe')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, '真实环境-06-教学数据中心') | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,645 @@ | |||||
| import type { Locator, Page } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { TeachingContractMock } from './teaching-contract-mock' | |||||
| const visibleDialog = (page: Page) => page.locator('.el-dialog:visible').last() | |||||
| const chooseSelectOption = async (page: Page, scope: Locator, label: string, option: string) => { | |||||
| const formSelect = scope.locator('.el-form-item').filter({ hasText: label }).first().locator('.el-select') | |||||
| const select = await formSelect.count() | |||||
| ? formSelect | |||||
| : scope.locator('.el-select').filter({ hasText: label }).first() | |||||
| await select.click() | |||||
| const dropdown = page.locator('.el-select-dropdown:visible').last() | |||||
| await expect(dropdown.getByText(option, { exact: true })).toBeVisible() | |||||
| await dropdown.getByText(option, { exact: true }).click() | |||||
| } | |||||
| const switchIdentity = async (page: Page, contract: TeachingContractMock, identity: Parameters<TeachingContractMock['setIdentity']>[0]) => { | |||||
| contract.setIdentity(identity) | |||||
| await page.reload() | |||||
| } | |||||
| test.describe('教学实施模块状态化 UI 契约', () => { | |||||
| test('六个侧栏入口可达,指导书和训练记录可在模块内完成完整往返', async ({ page }, testInfo) => { | |||||
| await page.setViewportSize({ width: 1920, height: 1080 }) | |||||
| const contract = new TeachingContractMock('admin') | |||||
| contract.seedVirtualReviewedRun() | |||||
| await contract.install(page) | |||||
| const entries = [ | |||||
| ['/teaching/virtual-training', '虚拟仿真任务管理'], | |||||
| ['/teaching/physical-training', '实装实训任务管理'], | |||||
| ['/teaching/confrontation', '对抗训练任务管理'], | |||||
| ['/teaching/immersive', '沉浸交互与外设联调'], | |||||
| ['/teaching/exams', '考试测评'], | |||||
| ['/teaching/data-center', '教学数据中心'], | |||||
| ] as const | |||||
| for (const [path, heading] of entries) { | |||||
| await page.goto(path) | |||||
| await expect(page.getByRole('heading', { name: heading, exact: true })).toBeVisible() | |||||
| await expect(page.getByTestId('teaching-dashboard-hero').or(page.locator('.ttc-hero'))).toBeVisible() | |||||
| await expect(page.locator('iframe')).toHaveCount(0) | |||||
| } | |||||
| await page.goto('/teaching/virtual-training') | |||||
| await page.getByTestId('teaching-tab-guides').click() | |||||
| await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=guides$/) | |||||
| await expect(page.getByRole('heading', { name: '虚拟仿真作业指导书', exact: true })).toBeVisible() | |||||
| await expect(page.getByTestId('teaching-guide-library').locator('.guide-card-v2')).toHaveCount(1) | |||||
| await page.getByRole('button', { name: /打开阅读/ }).first().click() | |||||
| const guideReader = page.locator('.guide-reader-v2') | |||||
| await expect(guideReader).toBeVisible() | |||||
| await expect(guideReader).toContainText('01 安全准备') | |||||
| await expect(guideReader).toContainText('断电隔离') | |||||
| await guideReader.getByRole('button', { name: '下一步', exact: true }).click() | |||||
| await expect(guideReader.getByRole('heading', { name: '压力释放', exact: true })).toBeVisible() | |||||
| await expect(guideReader).toContainText('目标压力') | |||||
| await guideReader.getByRole('button', { name: '下一步', exact: true }).click() | |||||
| await expect(guideReader.getByRole('heading', { name: '复装与复测', exact: true })).toBeVisible() | |||||
| await expect(guideReader.getByRole('button', { name: '下一步', exact: true })).toBeDisabled() | |||||
| await captureScreenshot(page, testInfo, 'teaching-guide-full-reader') | |||||
| await page.getByRole('button', { name: /返回指导书列表/ }).click() | |||||
| await expect(page.locator('.guide-list-card')).toBeVisible() | |||||
| await page.getByTestId('teaching-tab-tasks').click() | |||||
| await expect(page).toHaveURL(/\/teaching\/virtual-training$/) | |||||
| await expect(page.getByTestId('teaching-assignment-TASK-VIRTUAL-001')).toBeVisible() | |||||
| await page.getByTestId('teaching-tab-records').click() | |||||
| await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records$/) | |||||
| await expect(page.locator('.ttc-record-table')).toBeVisible() | |||||
| await expect(page.locator('.ttc-record-table tbody tr')).toHaveCount(1) | |||||
| await page.getByRole('button', { name: '查看详情', exact: true }).first().click() | |||||
| const recordDialog = visibleDialog(page) | |||||
| await expect(recordDialog).toContainText('详情页仅展示任务结果与步骤快照') | |||||
| await expect(recordDialog).toContainText('92 分') | |||||
| await expect(recordDialog).toContainText('2 / 2') | |||||
| await captureScreenshot(page, testInfo, 'teaching-training-record-detail') | |||||
| await recordDialog.getByRole('button', { name: /返回记录列表/ }).click() | |||||
| await expect(page.locator('.ttc-record-table')).toBeVisible() | |||||
| await page.getByRole('button', { name: '完整过程', exact: true }).first().click() | |||||
| await expect(page).toHaveURL(/\/teaching\/virtual-training\/runs\/run-virtual-reviewed\?returnView=records&observe=1$/) | |||||
| await expect(page.locator('.teaching-runtime-v2')).toBeVisible() | |||||
| await expect(page.getByText('当前为教员/管理员观察席', { exact: true })).toBeVisible() | |||||
| await expect(page.getByText('完成:识别作业对象', { exact: true })).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: /完成|提交训练结果|开始训练/ })).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, 'teaching-training-record-full-process') | |||||
| await page.locator('.runtime-back').click() | |||||
| await expect(page).toHaveURL(/\/teaching\/virtual-training\?view=records$/) | |||||
| await expect(page.locator('.ttc-record-table')).toBeVisible() | |||||
| await page.getByTestId('teaching-tab-tasks').click() | |||||
| await expect(page).toHaveURL(/\/teaching\/virtual-training$/) | |||||
| await expect(page.locator('iframe')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, 'teaching-module-guide-record-roundtrip') | |||||
| }) | |||||
| test('对抗任务监控可切换运行、进入三维只读观察并按最终 API 契约注入故障', async ({ page }, testInfo) => { | |||||
| await page.setViewportSize({ width: 1920, height: 1080 }) | |||||
| const contract = new TeachingContractMock('teacher') | |||||
| contract.seedConfrontationMonitorRuns() | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/confrontation') | |||||
| await page.getByTestId('teaching-monitor-TASK-CONFRONTATION-001').click() | |||||
| await expect(page).toHaveURL(/\/teaching\/confrontation\/tasks\/assignment-confrontation\/monitor$/) | |||||
| const monitor = page.getByTestId('confrontation-monitor') | |||||
| await expect(monitor).toBeVisible() | |||||
| await expect(monitor.locator('.monitor-run-list button')).toHaveCount(2) | |||||
| await expect(page.getByTestId('confrontation-monitor-scene').locator('canvas')).toBeVisible({ timeout: 30_000 }) | |||||
| await expect(page.getByTestId('confrontation-monitor-run-run-confrontation')).toHaveClass(/active/) | |||||
| await expect(page.getByTestId('confrontation-monitor-run-run-confrontation')).toContainText('RED') | |||||
| await page.getByTestId('confrontation-monitor-run-run-confrontation-blue').click() | |||||
| await expect(page.getByTestId('confrontation-monitor-run-run-confrontation-blue')).toHaveClass(/active/) | |||||
| await expect(page.getByTestId('confrontation-monitor-run-run-confrontation-blue')).toContainText('BLUE') | |||||
| await expect(monitor.locator('.monitor-event-timeline')).toContainText('尚未产生过程事件') | |||||
| await captureScreenshot(page, testInfo, 'teaching-confrontation-monitor-switch') | |||||
| await page.getByTestId('confrontation-monitor-inject').click() | |||||
| const injectionDialog = visibleDialog(page) | |||||
| await expect(injectionDialog).toContainText('故障真值仅写入服务端私密载荷') | |||||
| const publicSummary = '训练设备出现动臂响应迟缓与压力波动,请按岗位流程排查。' | |||||
| await injectionDialog.locator('textarea').fill(publicSummary) | |||||
| await injectionDialog.getByRole('button', { name: '确认注入', exact: true }).click() | |||||
| await expect(injectionDialog).toBeHidden() | |||||
| await expect(monitor.locator('.monitor-interventions')).toContainText(publicSummary) | |||||
| const createIntervention = contract.last('POST', '/assignments/assignment-confrontation/interventions') | |||||
| expect(createIntervention?.body).toMatchObject({ | |||||
| type: 'FAULT', | |||||
| targetScope: 'ASSIGNMENT', | |||||
| publicSummary, | |||||
| publicPayload: { assignmentFaultId: 'assignment-fault-public', faultCode: 'HYDRAULIC.VALVE.STUCK' }, | |||||
| privatePayload: { | |||||
| assignmentFaultId: 'assignment-fault-public', | |||||
| faultCode: 'HYDRAULIC.VALVE.STUCK', | |||||
| privateTruth: { text: '液压阀芯卡滞真值' }, | |||||
| }, | |||||
| }) | |||||
| expect(JSON.stringify(createIntervention?.body.publicPayload)).not.toContain('液压阀芯卡滞真值') | |||||
| expect(contract.count('POST', /\/interventions\/[^/]+\/activate$/)).toBe(1) | |||||
| await page.getByTestId('confrontation-monitor-run-run-confrontation').click() | |||||
| await page.getByTestId('confrontation-monitor-observe').click() | |||||
| await expect(page).toHaveURL(/\/teaching\/confrontation\/runs\/run-confrontation\?observe=1$/) | |||||
| const observerWorkbench = page.locator('.teaching-runtime-v2[data-channel="confrontation"]') | |||||
| await expect(observerWorkbench).toBeVisible() | |||||
| await expect(observerWorkbench.getByText('只读观察', { exact: true })).toBeVisible() | |||||
| await expect(observerWorkbench.getByText('当前为教员/管理员观察席', { exact: true })).toBeVisible() | |||||
| await expect(observerWorkbench.locator('.runtime-stage-card canvas')).toBeVisible({ timeout: 30_000 }) | |||||
| await expect(observerWorkbench.getByRole('button', { name: /提交岗位动作|完成当前岗位动作|提交团队结果/ })).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, 'teaching-confrontation-observer-runtime') | |||||
| }) | |||||
| test('虚拟、实装、对抗三通道运行工作台结构完整,正式考核仅投影当前步骤并锁定辅助能力', async ({ page }, testInfo) => { | |||||
| await page.setViewportSize({ width: 1920, height: 1080 }) | |||||
| const contract = new TeachingContractMock('studentLeader') | |||||
| contract.seedImmersiveRun() | |||||
| await contract.install(page) | |||||
| const workbenchCases = [ | |||||
| { | |||||
| path: '/teaching/virtual-training/runs/run-immersive', | |||||
| channel: 'virtual', | |||||
| expected: ['虚拟仿真 · 训练工作台', '数字教员', '规则检测目标'], | |||||
| screenshot: 'teaching-runtime-virtual-workbench', | |||||
| }, | |||||
| { | |||||
| path: '/teaching/physical-training/runs/run-physical', | |||||
| channel: 'physical', | |||||
| expected: ['实装实训 · 训练工作台', '现场事件接入', '当前事件通道'], | |||||
| screenshot: 'teaching-runtime-physical-workbench', | |||||
| }, | |||||
| { | |||||
| path: '/teaching/confrontation/runs/run-confrontation', | |||||
| channel: 'confrontation', | |||||
| expected: ['对抗训练 · 训练工作台', '团队与岗位', '故障与异常'], | |||||
| screenshot: 'teaching-runtime-confrontation-workbench', | |||||
| }, | |||||
| ] as const | |||||
| for (const item of workbenchCases) { | |||||
| await page.goto(item.path) | |||||
| const workbench = page.locator(`.teaching-runtime-v2[data-channel="${item.channel}"]`) | |||||
| await expect(workbench).toBeVisible() | |||||
| await expect(workbench.locator('.runtime-left-rail')).toBeVisible() | |||||
| await expect(workbench.locator('.runtime-stage-card canvas')).toBeVisible({ timeout: 30_000 }) | |||||
| await expect(workbench.locator('.runtime-right-rail')).toBeVisible() | |||||
| await expect(workbench.locator('.runtime-operation-dock')).toBeVisible() | |||||
| for (const copy of item.expected) await expect(workbench.getByText(copy, { exact: true }).first()).toBeVisible() | |||||
| await expect(workbench.locator('iframe')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, item.screenshot) | |||||
| } | |||||
| await page.goto('/teaching/exams/runs/run-exam') | |||||
| const examWorkbench = page.locator('.teaching-runtime-v2[data-formal-exam="true"]') | |||||
| await expect(examWorkbench).toBeVisible() | |||||
| await expect(examWorkbench.getByText('正式考核保护已启用', { exact: true })).toBeVisible() | |||||
| await expect(examWorkbench.locator('.runtime-step-rail li')).toHaveCount(1) | |||||
| await expect(examWorkbench.locator('.runtime-step-rail>header')).toContainText('1 / 2') | |||||
| await expect(examWorkbench.getByRole('button', { name: /操作帮助|过程记录|上一步|回放|演示|提示/ })).toHaveCount(0) | |||||
| await expect(examWorkbench.locator('.digital-instructor-card')).toHaveCount(0) | |||||
| await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(0) | |||||
| await captureScreenshot(page, testInfo, 'teaching-runtime-formal-exam-lock') | |||||
| }) | |||||
| test('沉浸交互的本地按键配置可保存、刷新恢复并一键回到默认值', async ({ page }, testInfo) => { | |||||
| await page.setViewportSize({ width: 1920, height: 1080 }) | |||||
| const contract = new TeachingContractMock('studentLeader') | |||||
| contract.seedImmersiveRun() | |||||
| contract.keepOnlyRuns('run-immersive') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/immersive') | |||||
| const mappingPanel = page.locator('.xr-v2-mapping-panel') | |||||
| await expect(mappingPanel).toBeVisible() | |||||
| const triggerMapping = mappingPanel.locator('label').filter({ hasText: '扳机键' }) | |||||
| await triggerMapping.locator('.el-select').click() | |||||
| await page.locator('.el-select-dropdown:visible').last().getByText('确认当前操作', { exact: true }).click() | |||||
| await page.getByRole('button', { name: '保存本地配置', exact: true }).click() | |||||
| const saved = await page.evaluate(() => JSON.parse(window.localStorage.getItem('unreal-tran:teaching-xr-preferences:v1') || '{}') as { interactionMode?: string; mappings?: Record<string, string> }) | |||||
| expect(saved).toMatchObject({ interactionMode: 'DESKTOP', mappings: { trigger: '确认当前操作' } }) | |||||
| await expect(page.getByText('本地交互配置已保存', { exact: true }).first()).toBeVisible() | |||||
| await captureScreenshot(page, testInfo, 'teaching-immersive-local-config-saved') | |||||
| await page.reload() | |||||
| const restoredPanel = page.locator('.xr-v2-mapping-panel') | |||||
| const restoredTrigger = restoredPanel.locator('label').filter({ hasText: '扳机键' }) | |||||
| await expect(restoredTrigger.locator('.el-select')).toContainText('确认当前操作') | |||||
| await restoredPanel.getByRole('button', { name: '恢复默认', exact: true }).click() | |||||
| await expect(restoredTrigger.locator('.el-select')).toContainText('抓取 / 释放零件') | |||||
| expect(await page.evaluate(() => window.localStorage.getItem('unreal-tran:teaching-xr-preferences:v1'))).toBeNull() | |||||
| await expect(page.getByTestId('immersive-event-log')).toContainText('本地交互配置已恢复默认') | |||||
| await captureScreenshot(page, testInfo, 'teaching-immersive-local-config-restored') | |||||
| }) | |||||
| test('纯行政只读加载任务时不请求 runs,数据中心仅展示部门聚合', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('administrative') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/virtual-training') | |||||
| await expect(page.getByText('虚拟维修训练', { exact: true })).toBeVisible() | |||||
| await expect(page.getByText('部门范围只读', { exact: true })).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: /新建任务/ })).toHaveCount(0) | |||||
| expect(contract.count('GET', '/runs')).toBe(0) | |||||
| expect(contract.administrativeRunsRejected).toBe(0) | |||||
| await page.getByRole('button', { name: '查看', exact: true }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await expect(drawer).toContainText('虚拟维修训练') | |||||
| await expect(drawer.getByText('执行与评定记录', { exact: true })).toHaveCount(0) | |||||
| await expect(drawer.getByRole('button', { name: '查看运行' })).toHaveCount(0) | |||||
| expect(contract.count('GET', /\/runs$/)).toBe(0) | |||||
| await page.goto('/teaching/data-center') | |||||
| await expect(page.getByText('部门范围只读汇总', { exact: true })).toBeVisible() | |||||
| await expect(page.getByTestId('analytics-learner-table')).toHaveCount(0) | |||||
| expect(contract.count('GET', '/analytics/records')).toBe(0) | |||||
| expect(contract.administrativeAnalyticsRecordsRejected).toBe(0) | |||||
| await captureScreenshot(page, testInfo, 'teaching-administrative-readonly') | |||||
| }) | |||||
| test('UNION 行政加学员可识别本人运行,并完成 COMMON 领取到教员评定闭环', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('unionAdministrativeStudent') | |||||
| const definition = contract.assignments.get('assignment-virtual-common')?.definitionSnapshot as { steps: Array<Record<string, unknown>> } | |||||
| expect(definition.steps.every((step) => typeof step.stepCode === 'string' && !('code' in step) && !('id' in step))).toBe(true) | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/virtual-training') | |||||
| await expect(page.getByText('虚拟维修训练', { exact: true })).toBeVisible() | |||||
| await expect(page.getByText('部门范围只读', { exact: true })).toHaveCount(0) | |||||
| await page.getByRole('button', { name: '领取任务', exact: true }).click() | |||||
| await expect(page).toHaveURL(/\/teaching\/virtual-training\/runs\/run-virtual-common$/) | |||||
| await expect(page.getByRole('button', { name: '开始训练', exact: true })).toBeVisible() | |||||
| const acceptRequest = contract.last('POST', '/assignments/assignment-virtual-common/accept') | |||||
| expect(acceptRequest?.body).toEqual({ | |||||
| assignmentVersion: 3, | |||||
| teamCode: 'NEUTRAL', | |||||
| groupCode: 'INDIVIDUAL', | |||||
| positionCode: 'LEARNER', | |||||
| }) | |||||
| await page.getByRole('button', { name: '开始训练', exact: true }).click() | |||||
| await page.getByRole('button', { name: '完成并进入下一步', exact: true }).click() | |||||
| await expect(page.getByRole('button', { name: '完成当前步骤', exact: true })).toBeVisible() | |||||
| await page.getByRole('button', { name: '完成当前步骤', exact: true }).click() | |||||
| await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible() | |||||
| const progressRequests = contract.requests.filter((item) => item.method === 'PUT' && item.path === '/runs/run-virtual-common/progress') | |||||
| expect(progressRequests).toHaveLength(2) | |||||
| expect(progressRequests.map((item) => item.body.completedStepCode)).toEqual(['STEP-01', 'STEP-02']) | |||||
| for (const request of progressRequests) { | |||||
| expect(request.body).not.toHaveProperty('percent') | |||||
| expect(request.body).not.toHaveProperty('currentStepCode') | |||||
| } | |||||
| await page.getByRole('button', { name: '提交训练结果', exact: true }).click() | |||||
| await visibleDialog(page).getByRole('button', { name: '确认提交', exact: true }).click() | |||||
| await expect(page.getByText('待评定', { exact: true }).first()).toBeVisible() | |||||
| expect(contract.runs.get('run-virtual-common')?.status).toBe('SUBMITTED') | |||||
| await page.goto('/teaching/data-center') | |||||
| await expect.poll(() => contract.count('GET', '/analytics/records')).toBeGreaterThan(0) | |||||
| expect(contract.last('GET', '/analytics/records')?.query.get('userId')).toBe('4') | |||||
| await expect(page.locator('.teaching-v2-filter-toolbar').getByText('全部部门', { exact: true })).toHaveCount(0) | |||||
| await expect(page.locator('.teaching-v2-filter-toolbar').getByText('全部学员', { exact: true })).toHaveCount(0) | |||||
| await switchIdentity(page, contract, 'teacher') | |||||
| await page.goto('/teaching/virtual-training') | |||||
| await page.getByRole('button', { name: '查看', exact: true }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await expect(drawer.getByRole('button', { name: '评定', exact: true })).toBeVisible() | |||||
| await drawer.getByRole('button', { name: '评定', exact: true }).click() | |||||
| await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click() | |||||
| await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible() | |||||
| expect(contract.runs.get('run-virtual-common')?.status).toBe('REVIEWED') | |||||
| expect(contract.count('POST', '/runs/run-virtual-common/review')).toBe(1) | |||||
| await captureScreenshot(page, testInfo, 'teaching-common-stateful-review') | |||||
| }) | |||||
| test('无评定权限不显示超时入口,教员可将超时运行结算后评定', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('administrative') | |||||
| contract.seedExpiredRun() | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/virtual-training') | |||||
| await page.getByRole('button', { name: '查看', exact: true }).click() | |||||
| await expect(page.locator('.el-drawer:visible').getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0) | |||||
| expect(contract.count('POST', '/runs/run-timeout/timeout')).toBe(0) | |||||
| await switchIdentity(page, contract, 'teacher') | |||||
| await page.getByRole('button', { name: '查看', exact: true }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await expect(drawer.getByRole('button', { name: '超时结算', exact: true })).toBeVisible() | |||||
| await expect(drawer.getByRole('button', { name: '结束运行', exact: true })).toHaveCount(0) | |||||
| await drawer.getByRole('button', { name: '超时结算', exact: true }).click() | |||||
| const timeoutDialog = visibleDialog(page) | |||||
| await expect(timeoutDialog.getByRole('heading', { name: '确认超时结算', exact: true })).toBeVisible() | |||||
| await expect(timeoutDialog).toContainText('结算后该运行进入待评定状态') | |||||
| await timeoutDialog.getByRole('button', { name: '确认结算', exact: true }).click() | |||||
| await expect(page.getByText('运行已超时结算,可继续进行评定', { exact: true })).toBeVisible() | |||||
| await expect(timeoutDialog).toBeHidden() | |||||
| await expect(drawer.locator('.el-loading-mask:visible')).toHaveCount(0) | |||||
| expect(contract.last('POST', '/runs/run-timeout/timeout')?.body).toEqual({ version: 2 }) | |||||
| expect(contract.runs.get('run-timeout')?.status).toBe('SUBMITTED') | |||||
| await expect(drawer.getByRole('button', { name: '评定', exact: true })).toBeVisible() | |||||
| await drawer.getByRole('button', { name: '评定', exact: true }).click() | |||||
| await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click() | |||||
| await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible() | |||||
| expect(contract.runs.get('run-timeout')?.status).toBe('REVIEWED') | |||||
| expect(contract.last('POST', '/runs/run-timeout/review')?.body).toMatchObject({ version: 3, score: 80, passed: true }) | |||||
| await captureScreenshot(page, testInfo, 'teaching-timeout-settlement-review') | |||||
| }) | |||||
| test('教员可填写原因中途结束未超时运行并继续评定', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('teacher') | |||||
| contract.seedTerminableRun() | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/virtual-training') | |||||
| await page.getByRole('button', { name: '查看', exact: true }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await expect(drawer.getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0) | |||||
| await expect(drawer.getByRole('button', { name: '结束运行', exact: true })).toBeVisible() | |||||
| await drawer.getByRole('button', { name: '结束运行', exact: true }).click() | |||||
| const terminateDialog = visibleDialog(page) | |||||
| await expect(terminateDialog.getByRole('heading', { name: '结束运行', exact: true })).toBeVisible() | |||||
| await expect(terminateDialog).toContainText('人工中止') | |||||
| await terminateDialog.getByRole('button', { name: '确认结束', exact: true }).click() | |||||
| expect(contract.count('POST', '/runs/run-terminate/terminate')).toBe(0) | |||||
| await expect(terminateDialog).toBeVisible() | |||||
| const reason = '现场安全条件不满足,教员人工结束运行。' | |||||
| await terminateDialog.getByPlaceholder('请输入结束运行原因').fill(reason) | |||||
| await terminateDialog.getByRole('button', { name: '确认结束', exact: true }).click() | |||||
| await expect(page.getByText('运行已结束并进入待评定状态', { exact: true })).toBeVisible() | |||||
| await expect(terminateDialog).toBeHidden() | |||||
| await expect(drawer.locator('.el-loading-mask:visible')).toHaveCount(0) | |||||
| expect(contract.last('POST', '/runs/run-terminate/terminate')?.body).toEqual({ version: 2, reason }) | |||||
| expect(contract.runs.get('run-terminate')?.status).toBe('SUBMITTED') | |||||
| await expect(drawer.getByRole('button', { name: '评定', exact: true })).toBeVisible() | |||||
| await drawer.getByRole('button', { name: '评定', exact: true }).click() | |||||
| await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click() | |||||
| await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible() | |||||
| expect(contract.runs.get('run-terminate')?.status).toBe('REVIEWED') | |||||
| expect(contract.last('POST', '/runs/run-terminate/review')?.body).toMatchObject({ version: 3, score: 80, passed: true }) | |||||
| await captureScreenshot(page, testInfo, 'teaching-terminate-settlement-review') | |||||
| }) | |||||
| test('对抗未开训运行可人工结束,已开训运行只能走回合超时结算', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('teacher') | |||||
| contract.seedConfrontationTerminationCases() | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/confrontation') | |||||
| await page.getByRole('button', { name: '查看', exact: true }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| const activeRow = drawer.getByRole('row').filter({ hasText: '进行中' }) | |||||
| await expect(activeRow.getByRole('button', { name: '超时结算', exact: true })).toBeVisible() | |||||
| await expect(activeRow.getByRole('button', { name: '结束运行', exact: true })).toHaveCount(0) | |||||
| const acceptedRow = drawer.getByRole('row').filter({ hasText: '待开始' }) | |||||
| await expect(acceptedRow.getByRole('button', { name: '超时结算', exact: true })).toHaveCount(0) | |||||
| await acceptedRow.getByRole('button', { name: '结束运行', exact: true }).click() | |||||
| const dialog = visibleDialog(page) | |||||
| const reason = '对抗编组条件未就绪,开训前结束本次运行。' | |||||
| await dialog.getByPlaceholder('请输入结束运行原因').fill(reason) | |||||
| await dialog.getByRole('button', { name: '确认结束', exact: true }).click() | |||||
| await expect(page.getByText('运行已结束并进入待评定状态', { exact: true })).toBeVisible() | |||||
| await expect(dialog).toBeHidden() | |||||
| await expect(drawer.locator('.el-loading-mask:visible')).toHaveCount(0) | |||||
| expect(contract.last('POST', '/runs/run-confrontation-queued/terminate')?.body).toEqual({ version: 2, reason }) | |||||
| expect(contract.runs.get('run-confrontation-queued')?.status).toBe('SUBMITTED') | |||||
| expect(contract.count('POST', '/runs/run-confrontation/terminate')).toBe(0) | |||||
| const submittedRow = drawer.getByRole('row').filter({ hasText: '待评定' }) | |||||
| await submittedRow.getByRole('button', { name: '评定', exact: true }).click() | |||||
| await visibleDialog(page).getByRole('button', { name: '提交评定', exact: true }).click() | |||||
| await expect(page.getByText('评定结果已提交', { exact: true })).toBeVisible() | |||||
| expect(contract.runs.get('run-confrontation-queued')?.status).toBe('REVIEWED') | |||||
| await captureScreenshot(page, testInfo, 'teaching-confrontation-prestart-terminate') | |||||
| }) | |||||
| test('实装训练两步只走 physical-events,服务端响应驱动下一待办步骤', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('studentLeader') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/physical-training/runs/run-physical') | |||||
| await expect(page.getByText('现场事件接入', { exact: true })).toBeVisible() | |||||
| await page.getByRole('button', { name: '开始训练', exact: true }).click() | |||||
| const confirm = page.getByRole('button', { name: '确认已完成当前实装步骤', exact: true }) | |||||
| await expect(confirm).toBeVisible() | |||||
| await confirm.click() | |||||
| await expect(page.getByText('确认液压卸压', { exact: true })).toBeVisible() | |||||
| await confirm.click() | |||||
| await expect(page.getByRole('button', { name: '提交训练结果', exact: true })).toBeVisible() | |||||
| const requests = contract.requests.filter((item) => item.method === 'POST' && item.path === '/runs/run-physical/physical-events') | |||||
| expect(requests).toHaveLength(2) | |||||
| expect(requests.map((item) => item.body.stepCode)).toEqual(['STEP-01', 'STEP-02']) | |||||
| expect(requests.map((item) => item.body.eventType)).toEqual(['MANUAL_CONFIRM_POWER_OFF', 'MANUAL_CONFIRM_PRESSURE_RELEASED']) | |||||
| expect(requests.every((item) => item.body.source === 'MANUAL' && item.body.manualConfirmed === true)).toBe(true) | |||||
| expect(requests.every((item) => Number(item.body.occurredAt) < 1_000_000_000_000)).toBe(true) | |||||
| expect(contract.count('PUT', '/runs/run-physical/progress')).toBe(0) | |||||
| expect(contract.runs.get('run-physical')?.progressPercent).toBe(100) | |||||
| await captureScreenshot(page, testInfo, 'teaching-physical-two-step-events') | |||||
| }) | |||||
| test('指定任务预创建的待接收运行可完成接收并进入任务', async ({ page }) => { | |||||
| const contract = new TeachingContractMock('studentLeader') | |||||
| const pendingRun = contract.runs.get('run-physical') | |||||
| if (!pendingRun) throw new Error('缺少指定任务运行夹具') | |||||
| pendingRun.status = 'PENDING' | |||||
| pendingRun.acceptedAt = null | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/physical-training') | |||||
| const card = page.locator('.ttc-assignment-card').filter({ hasText: '实装维修训练' }) | |||||
| await expect(card.getByRole('button', { name: '接收任务', exact: true })).toBeVisible() | |||||
| await card.getByRole('button', { name: '接收任务', exact: true }).click() | |||||
| await expect(page).toHaveURL(/\/teaching\/physical-training\/runs\/run-physical$/) | |||||
| expect(contract.count('POST', '/assignments/assignment-physical/accept')).toBe(1) | |||||
| expect(contract.runs.get('run-physical')?.status).toBe('ACCEPTED') | |||||
| }) | |||||
| test('对抗共享运行按岗位推进、负责人提交,并仅展示故障公开投影与 TEAM 编组干预', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('studentOperator') | |||||
| await contract.install(page) | |||||
| const faultResponsePromise = page.waitForResponse((response) => response.url().includes('/assignments/assignment-confrontation/faults')) | |||||
| await page.goto('/teaching/confrontation/runs/run-confrontation') | |||||
| const faultResponse = await faultResponsePromise | |||||
| const faultPayload = await faultResponse.json() as { data: unknown } | |||||
| expect(JSON.stringify(faultPayload.data)).not.toContain('液压阀芯卡滞真值') | |||||
| expect(JSON.stringify(faultPayload.data)).not.toContain('内部触发条件') | |||||
| await expect(page.getByText('动臂响应迟缓且压力波动', { exact: true })).toBeVisible() | |||||
| await expect(page.getByText('液压阀芯卡滞真值')).toHaveCount(0) | |||||
| await expect(page.getByText('红蓝双方按岗位协同完成液压故障隔离', { exact: true })).toBeVisible() | |||||
| await expect(page.getByText('在安全约束下按完成度与用时综合判定', { exact: true })).toBeVisible() | |||||
| await expect(page.getByText(/红方维修组 · OPERATOR/)).toBeVisible() | |||||
| await expect(page.getByText(/此动作需由 TEAM_LEADER/)).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: '提交岗位动作', exact: true })).toBeDisabled() | |||||
| await switchIdentity(page, contract, 'studentLeader') | |||||
| await page.getByRole('button', { name: '提交岗位动作', exact: true }).click() | |||||
| expect(contract.last('PUT', '/runs/run-confrontation/progress')?.body).toMatchObject({ completedStepCode: 'STEP-01', actionCode: 'ISSUE_COMMAND' }) | |||||
| await switchIdentity(page, contract, 'studentOperator') | |||||
| await expect(page.getByText(/当前动作由岗位“OPERATOR”执行/)).toBeVisible() | |||||
| await page.getByRole('button', { name: '完成当前岗位动作', exact: true }).click() | |||||
| await expect(page.getByText('等待组长/指挥员提交', { exact: true })).toBeVisible() | |||||
| expect(contract.last('PUT', '/runs/run-confrontation/progress')?.body).toMatchObject({ completedStepCode: 'STEP-02', actionCode: 'ISOLATE_FAULT' }) | |||||
| await switchIdentity(page, contract, 'studentLeader') | |||||
| await page.getByRole('button', { name: '提交团队结果', exact: true }).click() | |||||
| await visibleDialog(page).getByRole('button', { name: '确认提交', exact: true }).click() | |||||
| expect(contract.runs.get('run-confrontation')?.status).toBe('SUBMITTED') | |||||
| await switchIdentity(page, contract, 'teacher') | |||||
| await page.getByRole('button', { name: '新建干预', exact: true }).click() | |||||
| const dialog = visibleDialog(page) | |||||
| await chooseSelectOption(page, dialog, '作用层级', '指定队伍') | |||||
| await chooseSelectOption(page, dialog, '目标队伍', '红方') | |||||
| await dialog.locator('input[placeholder="可选:进一步限定编组"]').fill('GROUP-RED') | |||||
| await dialog.locator('textarea[placeholder="请输入不泄露故障答案的公开说明"]').fill('红方液压压力波动,请按岗位流程处置。') | |||||
| await dialog.getByRole('button', { name: '保存干预', exact: true }).click() | |||||
| await expect(page.getByText('红方液压压力波动,请按岗位流程处置。', { exact: true })).toBeVisible() | |||||
| await page.getByRole('button', { name: '激活', exact: true }).click() | |||||
| const intervention = contract.last('POST', '/assignments/assignment-confrontation/interventions') | |||||
| expect(intervention?.body).toMatchObject({ targetScope: 'TEAM', targetTeamCode: 'RED', targetGroupCode: 'GROUP-RED' }) | |||||
| expect(intervention?.body).not.toHaveProperty('privatePayload') | |||||
| expect(contract.interventions[0]?.status).toBe('ACTIVE') | |||||
| await captureScreenshot(page, testInfo, 'teaching-confrontation-role-and-intervention') | |||||
| }) | |||||
| test('正式考核提交 Unix 秒并锁定所有辅助能力', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('teacher') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/exams') | |||||
| await page.getByRole('button', { name: '新建正式考核', exact: true }).click() | |||||
| const dialog = visibleDialog(page) | |||||
| await dialog.getByRole('textbox', { name: /任务名称/ }).fill('状态化契约正式考核') | |||||
| await chooseSelectOption(page, dialog, '训练内容', '挖掘机液压系统训练 · V2') | |||||
| await dialog.getByRole('combobox', { name: '开始时间' }).fill('2026-08-11 09:00:00') | |||||
| await dialog.getByRole('combobox', { name: '截止时间' }).fill('2026-08-11 11:00:00') | |||||
| await chooseSelectOption(page, dialog, '参训学员', '红方队长 · 维修教研室') | |||||
| await page.keyboard.press('Escape') | |||||
| await dialog.getByRole('button', { name: '保存草稿', exact: true }).click() | |||||
| await expect(dialog).toBeHidden() | |||||
| const createRequest = contract.last('POST', '/assignments') | |||||
| expect(createRequest?.body).toMatchObject({ assignmentKind: 'FORMAL_EXAM', executionMode: 'EXAM', digitalHumanAllowed: false }) | |||||
| expect(typeof createRequest?.body.scheduleStartAt).toBe('number') | |||||
| expect(typeof createRequest?.body.dueAt).toBe('number') | |||||
| expect(Number(createRequest?.body.scheduleStartAt)).toBeLessThan(1_000_000_000_000) | |||||
| expect(Number(createRequest?.body.dueAt)).toBeGreaterThan(Number(createRequest?.body.scheduleStartAt)) | |||||
| contract.keepOnlyRuns('run-exam') | |||||
| await switchIdentity(page, contract, 'studentLeader') | |||||
| await page.goto('/teaching/exams/runs/run-exam') | |||||
| await expect(page.getByText('正式考核保护已启用', { exact: true })).toBeVisible() | |||||
| await expect(page.getByText(/关闭演示、步骤提示、回退、答案和数字教员/)).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: /演示|提示|回退|回放|上一步/ })).toHaveCount(0) | |||||
| await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(0) | |||||
| await page.goto('/teaching/immersive') | |||||
| await expect(page.getByText('正式考核禁止数字人、提示和演示。', { exact: true })).toBeVisible() | |||||
| await expect(page.getByText('本地数字人服务', { exact: true })).toHaveCount(0) | |||||
| await expect.poll(() => contract.count('GET', '/runs/run-exam/assets')).toBeGreaterThan(0) | |||||
| await expect.poll(() => contract.count('GET', '/runs/run-exam/execution')).toBeGreaterThan(1) | |||||
| await captureScreenshot(page, testInfo, 'teaching-formal-exam-seconds-and-lock') | |||||
| }) | |||||
| test('沉浸执行权限缺失时启动检查与桌面 WebXR 入口前置只读', async ({ page }) => { | |||||
| const contract = new TeachingContractMock('studentLeader') | |||||
| contract.seedImmersiveRun() | |||||
| contract.keepOnlyRuns('run-immersive') | |||||
| contract.denyPermissions('teaching.immersive.execute') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/immersive') | |||||
| const preflight = page.getByTestId('immersive-preflight') | |||||
| await expect(preflight).toContainText('当前身份仅可查看,无沉浸训练执行权限') | |||||
| await expect(preflight).toContainText('不会创建服务端会话,也不会申请浏览器 WebXR 会话') | |||||
| const desktopStarts = page.getByRole('button', { name: /只读查看,无法启动/ }) | |||||
| await expect(desktopStarts).toHaveCount(2) | |||||
| for (const button of await desktopStarts.all()) await expect(button).toBeDisabled() | |||||
| await expect(page.getByRole('button', { name: 'VR 沉浸模式' })).toBeDisabled() | |||||
| expect(contract.count('POST', /\/runs\/[^/]+\/xr-sessions$/)).toBe(0) | |||||
| }) | |||||
| test('运行场景与 WebXR 消费 definitionSnapshot 和教学资产,并完成桌面会话登记与结束', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('studentLeader') | |||||
| contract.seedImmersiveRun() | |||||
| contract.keepOnlyRuns('run-immersive') | |||||
| await contract.install(page) | |||||
| let modelRequests = 0 | |||||
| page.on('request', (request) => { if (new URL(request.url()).pathname === '/models/excavator-a.glb') modelRequests += 1 }) | |||||
| await page.goto('/teaching/immersive') | |||||
| await expect(page.getByRole('heading', { name: '沉浸交互与外设联调', exact: true })).toBeVisible() | |||||
| await expect.poll(() => contract.count('GET', '/runs/run-immersive/assets')).toBeGreaterThan(0) | |||||
| await expect(page.locator('.immersive-scene-status')).toContainText('已加载“虚拟维修训练”发布场景', { timeout: 30_000 }) | |||||
| await expect.poll(() => modelRequests).toBeGreaterThan(0) | |||||
| await expect(page.locator('.immersive-viewer canvas')).toBeVisible() | |||||
| await expect(page.locator('iframe')).toHaveCount(0) | |||||
| await expect(page.getByTestId('immersive-device-panel')).toBeVisible() | |||||
| await expect(page.getByTestId('immersive-calibration')).toBeVisible() | |||||
| await expect(page.getByTestId('immersive-preflight')).toBeVisible() | |||||
| await page.getByRole('button', { name: '启动桌面会话', exact: true }).click() | |||||
| await expect(page.getByRole('button', { name: '结束会话', exact: true })).toBeVisible() | |||||
| expect(contract.last('POST', '/runs/run-immersive/xr-sessions')?.body).toMatchObject({ mode: 'INLINE', referenceSpaceType: 'LOCAL' }) | |||||
| await page.getByRole('button', { name: '结束会话', exact: true }).click() | |||||
| await expect(page.getByRole('button', { name: '结束会话', exact: true })).toHaveCount(0) | |||||
| expect(contract.count('PUT', '/xr-sessions/xr-session-1/end')).toBe(1) | |||||
| await captureScreenshot(page, testInfo, 'teaching-real-assets-and-xr-session') | |||||
| }) | |||||
| test('数据中心查询分页安全明细,并以 Unix 秒提交时间筛选', async ({ page }, testInfo) => { | |||||
| const contract = new TeachingContractMock('teacher') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/data-center') | |||||
| await expect(page.getByTestId('analytics-channel-summary')).toBeVisible() | |||||
| await page.getByTestId('analytics-tab-exam').click() | |||||
| await expect(page.getByText('虚拟维修正式考核', { exact: true })).toBeVisible() | |||||
| await page.locator('input[placeholder="搜索学员、编号或任务名称"]').fill('红方队长') | |||||
| await chooseSelectOption(page, page.locator('.teaching-v2-filter-toolbar'), '全部渠道', '虚拟仿真') | |||||
| await chooseSelectOption(page, page.locator('.teaching-v2-filter-toolbar'), '全部状态', '已评定') | |||||
| const toolbar = page.locator('.teaching-v2-filter-toolbar') | |||||
| await toolbar.getByRole('combobox', { name: '开始时间' }).fill('2026-08-01 00:00:00') | |||||
| await toolbar.getByRole('combobox', { name: '结束时间' }).fill('2026-08-12 00:00:00') | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| await expect.poll(() => contract.count('GET', '/analytics/records')).toBeGreaterThan(1) | |||||
| const query = contract.last('GET', '/analytics/records')?.query | |||||
| expect(query?.get('keyword')).toBe('红方队长') | |||||
| expect(query?.get('channel')).toBe('VIRTUAL') | |||||
| // 页签在已授权的安全明细内做客户端分组;服务端组合筛选不再重复提交类型字段。 | |||||
| expect(query?.get('assignmentKind')).toBeNull() | |||||
| expect(query?.get('status')).toBe('REVIEWED') | |||||
| expect(Number(query?.get('startAt'))).toBeLessThan(1_000_000_000_000) | |||||
| expect(Number(query?.get('endAt'))).toBeGreaterThan(Number(query?.get('startAt'))) | |||||
| await page.getByRole('button', { name: '详情', exact: true }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await expect(drawer).toContainText('教学记录详情') | |||||
| await expect(drawer).toContainText('此处只展示服务端授权的安全明细') | |||||
| await expect(drawer).not.toContainText('checkpoint') | |||||
| await expect(drawer).not.toContainText('privatePayload') | |||||
| await captureScreenshot(page, testInfo, 'teaching-analytics-safe-records') | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,46 @@ | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { TeachingContractMock } from './teaching-contract-mock' | |||||
| test.describe('教员只读预览工作台', () => { | |||||
| test('按原型呈现完整工作台且所有演练保持浏览器本地只读', async ({ page }, testInfo) => { | |||||
| await page.setViewportSize({ width: 1920, height: 1080 }) | |||||
| const contract = new TeachingContractMock('admin') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/virtual-training/preview/assignment-virtual-common') | |||||
| const workbench = page.locator('.teaching-preview-workbench') | |||||
| await expect(workbench).toBeVisible() | |||||
| await expect(workbench).toContainText('教员预览 · 不计成绩') | |||||
| await expect(workbench.locator('.preview-step-panel')).toContainText('识别作业对象') | |||||
| await expect(workbench.locator('.preview-runtime-viewport canvas')).toBeVisible() | |||||
| await expect(workbench.locator('.preview-digital-teacher')).toContainText('语音服务未接入') | |||||
| await expect(workbench.locator('.preview-digital-teacher img')).toHaveJSProperty('complete', true) | |||||
| await expect(workbench.locator('iframe')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, 'teaching-teacher-preview-initial') | |||||
| const writesBefore = contract.requests.filter((request) => ['POST', 'PUT', 'DELETE'].includes(request.method)).length | |||||
| await workbench.getByRole('button', { name: '判定详情', exact: true }).click() | |||||
| await expect(workbench.locator('.preview-rule-drawer')).toContainText('IDENTIFY_TARGET') | |||||
| await expect(workbench.locator('.preview-rule-drawer')).toContainText('这里只展示发布快照中的公开字段') | |||||
| await workbench.getByRole('button', { name: '引导完成', exact: true }).click() | |||||
| await expect(workbench.getByRole('button', { name: /下一步/ })).toBeEnabled() | |||||
| await workbench.getByRole('button', { name: /下一步/ }).click() | |||||
| await expect(workbench.getByRole('heading', { name: /第 2 步:完成检修操作/ })).toBeVisible() | |||||
| await workbench.getByRole('button', { name: /过程记录/ }).click() | |||||
| const drawer = page.locator('.el-drawer:visible') | |||||
| await expect(drawer).toContainText('不会提交到教学事件接口') | |||||
| await expect(drawer).toContainText('本地试走步骤') | |||||
| const writesAfter = contract.requests.filter((request) => ['POST', 'PUT', 'DELETE'].includes(request.method)).length | |||||
| expect(writesAfter).toBe(writesBefore) | |||||
| await page.keyboard.press('Escape') | |||||
| await expect(drawer).toBeHidden() | |||||
| await captureScreenshot(page, testInfo, 'teaching-teacher-preview-workbench') | |||||
| await workbench.getByRole('button', { name: '返回任务列表', exact: true }).click() | |||||
| await expect(page).toHaveURL(/\/teaching\/virtual-training$/) | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,68 @@ | |||||
| import { expect, test } from './fixtures' | |||||
| import { TeachingContractMock } from './teaching-contract-mock' | |||||
| test.describe('教学运行页 P1 安全边界', () => { | |||||
| test('observe/readOnly 强制本人学员进入只读观察', async ({ page }) => { | |||||
| const contract = new TeachingContractMock('studentLeader') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/confrontation/runs/run-confrontation?observe=1') | |||||
| await expect(page.getByText('只读观察', { exact: true })).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: /提交岗位动作|完成当前岗位动作|提交团队结果/ })).toHaveCount(0) | |||||
| await page.goto('/teaching/confrontation/runs/run-confrontation?readOnly=1') | |||||
| await expect(page.getByText('只读观察', { exact: true })).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: /提交岗位动作|完成当前岗位动作|提交团队结果/ })).toHaveCount(0) | |||||
| expect(contract.requests.filter((item) => ['PUT', 'POST', 'DELETE'].includes(item.method))).toHaveLength(0) | |||||
| }) | |||||
| test('正式考核提交后清空执行投影并卸载 Three 场景', async ({ page }) => { | |||||
| const contract = new TeachingContractMock('studentLeader') | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/exams/runs/run-exam') | |||||
| await expect(page.locator('.runtime-scene-window canvas')).toBeVisible() | |||||
| await page.getByRole('button', { name: '完成当前步骤', exact: true }).click() | |||||
| await expect(page.getByRole('heading', { name: /第 2 步/ })).toBeVisible() | |||||
| await page.getByRole('button', { name: '完成当前步骤', exact: true }).click() | |||||
| await page.getByRole('button', { name: '提交正式考核', exact: true }).click() | |||||
| const dialog = page.locator('.el-dialog:visible').last() | |||||
| await dialog.getByRole('button', { name: '确认提交', exact: true }).click() | |||||
| await expect(page.getByText('正式考核执行场景已关闭', { exact: true })).toBeVisible() | |||||
| await expect(page.locator('.runtime-scene-window canvas')).toHaveCount(0) | |||||
| await expect(page.getByRole('button', { name: /过程记录/ })).toHaveCount(0) | |||||
| await expect(page.locator('.runtime-event-card')).toHaveCount(0) | |||||
| }) | |||||
| test('共享对抗缺少岗位声明时只允许成员 ID 最小的本人', async ({ page }) => { | |||||
| const contract = new TeachingContractMock('studentOperator') | |||||
| const task = contract.assignments.get('assignment-confrontation') as Record<string, unknown> | |||||
| const definition = task.definitionSnapshot as { steps: Array<Record<string, unknown>>; modeConfig: Record<string, unknown> } | |||||
| definition.steps.forEach((step) => { | |||||
| delete step.allowedPositions | |||||
| delete step.requiredPositions | |||||
| delete step.positionCodes | |||||
| delete step.positionCode | |||||
| delete step.positionActions | |||||
| delete step.submitPositions | |||||
| }) | |||||
| delete definition.modeConfig.submitPositions | |||||
| const runtime = contract.runs.get('run-confrontation') as Record<string, unknown> | |||||
| const members = runtime.members as Array<Record<string, unknown>> | |||||
| const leader = members.find((member) => member.userId === '4')! | |||||
| const operator = members.find((member) => member.userId === '5')! | |||||
| leader.id = '9' | |||||
| operator.id = '2' | |||||
| runtime.member = leader | |||||
| await contract.install(page) | |||||
| await page.goto('/teaching/confrontation/runs/run-confrontation') | |||||
| await expect(page.getByRole('button', { name: '提交岗位动作', exact: true })).toBeEnabled() | |||||
| contract.setIdentity('studentLeader') | |||||
| await page.reload() | |||||
| await expect(page.getByRole('button', { name: '提交岗位动作', exact: true })).toBeDisabled() | |||||
| await expect(page.getByText(/此动作需由.*运行成员 ID 最小/)).toBeVisible() | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,301 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const departments = [ | |||||
| { | |||||
| id: '100', code: 'ORG-100', name: '某类装备维修实训数字车间', parentId: null, | |||||
| enabled: true, isRoot: true, sortOrder: 10, version: 1, | |||||
| children: [ | |||||
| { id: '110', code: 'ORG-110', name: '信息中心', parentId: '100', enabled: true, sortOrder: 10, version: 1, children: [] }, | |||||
| { | |||||
| id: '200', code: 'ORG-200', name: '维修改研室', parentId: '100', enabled: true, sortOrder: 20, version: 1, | |||||
| children: [ | |||||
| { id: '210', code: 'ORG-210', name: '维修三组', parentId: '200', enabled: true, sortOrder: 10, version: 1, children: [] }, | |||||
| ], | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| ] | |||||
| const roles = [ | |||||
| { id: '1', code: 'ADMIN', name: '管理员', shortName: '管', enabled: true, builtIn: true, isSuperAdmin: true, dataScopeCode: 'ALL', version: 1 }, | |||||
| { id: '2', code: 'TEACHER', name: '教员', shortName: '教', enabled: true, builtIn: true, isSuperAdmin: false, dataScopeCode: 'SYSTEM', version: 1 }, | |||||
| { id: '3', code: 'STUDENT', name: '学员', shortName: '学', enabled: true, builtIn: true, isSuperAdmin: false, dataScopeCode: 'SELF', version: 1 }, | |||||
| ] | |||||
| const users = [ | |||||
| { id: '1', username: 'admin', displayName: '系统管理员', departmentId: '110', departmentName: '信息中心', roleIds: ['1'], defaultRoleId: '1', enabled: true, isProtected: true, credentialConfigured: true, mustChangePassword: false, version: 1 }, | |||||
| { id: '2', username: 'teacher', displayName: '周教员', departmentId: '200', departmentName: '维修改研室', roleIds: ['2'], defaultRoleId: '2', enabled: true, isProtected: false, credentialConfigured: true, mustChangePassword: true, version: 1 }, | |||||
| { id: '3', username: 'student', displayName: '张伟', departmentId: '210', departmentName: '维修三组', roleIds: ['3'], defaultRoleId: '3', enabled: true, isProtected: false, credentialConfigured: false, mustChangePassword: true, version: 1 }, | |||||
| { id: '4', username: 'operator', displayName: '李教员', departmentId: '110', departmentName: '信息中心', roleIds: ['2'], defaultRoleId: '2', enabled: false, isProtected: false, credentialConfigured: false, mustChangePassword: true, version: 1 }, | |||||
| ] | |||||
| const json = (route: Route, data: unknown) => route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ code: 0, message: '成功', data, timestamp: Date.now(), requestId: 'e2e-mock' }), | |||||
| }) | |||||
| async function mockUserPageApi( | |||||
| page: Page, | |||||
| requestedDepartments: Array<string | null>, | |||||
| passwordResets: Array<Record<string, unknown>> = [], | |||||
| requestedAuditKeywords: Array<string | null> = [], | |||||
| ) { | |||||
| await page.route(/\/api\/(?:auth|tran)\//, async (route) => { | |||||
| const url = new URL(route.request().url()) | |||||
| const path = url.pathname | |||||
| if (path.endsWith('/auth/me')) { | |||||
| return json(route, { | |||||
| userId: '1', username: 'admin', displayName: '系统管理员', departmentId: '110', departmentName: '信息中心', | |||||
| activeRoleId: '1', roles, authorizationMode: 'SINGLE_ACTIVE', mustChangePassword: false, | |||||
| permissions: [ | |||||
| 'system.users', 'system.users.create', 'system.users.update', 'system.users.delete', 'system.users.reset-password', | |||||
| 'system.audit', 'system.audit.export', | |||||
| ], | |||||
| }) | |||||
| } | |||||
| if (path.endsWith('/departments/tree')) return json(route, departments) | |||||
| if (path.endsWith('/roles/all')) return json(route, roles) | |||||
| if (path.endsWith('/users/stats')) return json(route, { total: 4, enabled: 3, disabled: 1, passwordPending: 1 }) | |||||
| if (/\/users\/\d+\/password$/.test(path) && route.request().method() === 'PUT') { | |||||
| const request = route.request().postDataJSON() as Record<string, unknown> | |||||
| passwordResets.push(request) | |||||
| return json(route, { | |||||
| temporaryPassword: request.password ? null : 'System#Reset8A', | |||||
| mustChangePassword: true, | |||||
| }) | |||||
| } | |||||
| if (path.endsWith('/users')) { | |||||
| const departmentId = url.searchParams.get('departmentId') | |||||
| requestedDepartments.push(departmentId) | |||||
| const records = departmentId === '200' | |||||
| ? users.filter((user) => ['200', '210'].includes(user.departmentId)) | |||||
| : users | |||||
| return json(route, { records, total: records.length, page: 1, size: 10 }) | |||||
| } | |||||
| if (path.endsWith('/audit-logs')) { | |||||
| requestedAuditKeywords.push(url.searchParams.get('keyword')) | |||||
| const size = Number(url.searchParams.get('size') ?? 20) | |||||
| const records = Array.from({ length: size }, (_, index) => ({ | |||||
| id: String(index + 1), | |||||
| time: `2026-08-08T18:${String(index).padStart(2, '0')}:00`, | |||||
| user: index % 2 ? '周教员' : '系统管理员', | |||||
| moduleCode: index === 0 ? 'SYSTEM' : index === 1 ? 'AUTH' : index === 2 ? 'CONTENT' : index === 3 ? 'CUSTOM_AUDIT' : 'SYSTEM', | |||||
| action: index % 2 ? '编辑用户' : '登录系统', | |||||
| ip: '127.0.0.1', | |||||
| success: true, | |||||
| requestId: `audit-${index + 1}`, | |||||
| })) | |||||
| return json(route, { records, total: 42, page: 1, size }) | |||||
| } | |||||
| if (path.endsWith('/auth/ping')) return json(route, { service: 'ut-auth', port: 6101, status: 'UP' }) | |||||
| if (path.endsWith('/tran/ping')) return json(route, { service: 'ut-tran', port: 6102, status: 'UP' }) | |||||
| return json(route, {}) | |||||
| }) | |||||
| } | |||||
| test('用户列表全局中文并支持部门树选择与再次点击取消', async ({ page }, testInfo) => { | |||||
| const requestedDepartments: Array<string | null> = [] | |||||
| page.on('pageerror', (error) => console.error(`PAGE_ERROR: ${error.message}`)) | |||||
| page.on('console', (message) => { | |||||
| if (message.type() === 'error') console.error(`CONSOLE_ERROR: ${message.text()}`) | |||||
| }) | |||||
| await mockUserPageApi(page, requestedDepartments) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('unreal-tran:web:access-token:v1', 'e2e-mock-token') | |||||
| }) | |||||
| await page.goto('/system/users') | |||||
| // 页头卡已去掉,页面身份改由页面标签栏承担 | |||||
| await expect(page.getByRole('tab', { name: '用户管理', exact: true })).toBeVisible() | |||||
| await expect(page.locator('.el-pagination__sizes')).toContainText('条/页') | |||||
| await expect(page.locator('.el-pagination__jump')).toContainText('前往') | |||||
| await expect(page.locator('.system-pagination-total')).toHaveText('共 4 条记录') | |||||
| await expect(page.locator('.department-filter-panel')).toBeVisible() | |||||
| await expect(page.locator('.department-filter-panel.system-panel')).toBeVisible() | |||||
| await expect(page.locator('.user-list-main.system-panel.system-list-card')).toBeVisible() | |||||
| await expect(page.locator('.user-stats')).toHaveCount(0) | |||||
| const inlineStatistics = page.getByTestId('user-inline-statistics') | |||||
| await expect(inlineStatistics).toContainText('当前 4 条,共 4 个账号') | |||||
| await expect(inlineStatistics).toContainText('正常 3') | |||||
| await expect(inlineStatistics).toContainText('停用 1') | |||||
| await expect(inlineStatistics).toContainText('待改密 1') | |||||
| await captureScreenshot(page, testInfo, 'users-inline-statistics') | |||||
| await expect(page.locator('.users-table')).toHaveClass(/el-table--border/) | |||||
| await expect(page.locator('.users-table')).toHaveClass(/el-table--striped/) | |||||
| const pendingRow = page.locator('.users-table .el-table__row').filter({ hasText: '周教员' }) | |||||
| const unconfiguredRow = page.locator('.users-table .el-table__row').filter({ hasText: '张伟' }) | |||||
| const disabledRow = page.locator('.users-table .el-table__row').filter({ hasText: '李教员' }) | |||||
| await expect(pendingRow.getByText('待修改密码')).toBeVisible() | |||||
| await expect(unconfiguredRow.getByText('未设置登录密码')).toBeVisible() | |||||
| await expect(disabledRow.getByText('待修改密码')).toHaveCount(0) | |||||
| await expect(disabledRow.getByText('未设置登录密码')).toHaveCount(0) | |||||
| const actionLayout = await page.locator('.table-actions').first().evaluate((element) => { | |||||
| const style = getComputedStyle(element) | |||||
| const childMargins = Array.from(element.children).map((child) => getComputedStyle(child).marginLeft) | |||||
| return { justifyContent: style.justifyContent, columnGap: style.columnGap, childMargins } | |||||
| }) | |||||
| expect(actionLayout.justifyContent).toBe('flex-start') | |||||
| expect(actionLayout.columnGap).toBe('12px') | |||||
| expect(actionLayout.childMargins.every((margin) => margin === '0px')).toBe(true) | |||||
| const listCardIsUnified = await page.locator('.user-list-main').evaluate((card) => { | |||||
| const toolbar = card.querySelector('.system-list-toolbar') | |||||
| const body = card.querySelector('.system-list-body') | |||||
| return toolbar?.parentElement === card && body?.parentElement === card | |||||
| }) | |||||
| expect(listCardIsUnified).toBe(true) | |||||
| const keywordWidth = await page.locator('.user-list-main .system-list-toolbar .el-input').first() | |||||
| .evaluate((element) => element.getBoundingClientRect().width) | |||||
| expect(keywordWidth).toBeLessThanOrEqual(391) | |||||
| const panelsAreSeparate = await page.locator('.user-content-layout').evaluate((container) => { | |||||
| const departmentPanel = container.querySelector('.department-filter-panel') | |||||
| const listPanel = container.querySelector('.user-list-main') | |||||
| return departmentPanel !== listPanel | |||||
| && departmentPanel?.parentElement === container | |||||
| && listPanel?.parentElement === container | |||||
| }) | |||||
| expect(panelsAreSeparate).toBe(true) | |||||
| const normalFontSize = await page.locator('.users-table .el-table__body .cell').first().evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)) | |||||
| const hintFontSize = await page.locator('.department-filter-state').evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)) | |||||
| expect(normalFontSize).toBeGreaterThanOrEqual(14) | |||||
| expect(hintFontSize).toBeGreaterThanOrEqual(13) | |||||
| await page.getByRole('button', { name: '新增用户' }).click() | |||||
| const userDialog = page.locator('.el-dialog:visible') | |||||
| const dialogOverlay = page.locator('.el-overlay:visible') | |||||
| await expect(userDialog).toBeVisible() | |||||
| await expect(userDialog).toHaveClass(/is-draggable/) | |||||
| const dialogBox = await userDialog.boundingBox() | |||||
| const viewport = page.viewportSize() | |||||
| expect(dialogBox).not.toBeNull() | |||||
| expect(viewport).not.toBeNull() | |||||
| // Element Plus keeps a small safe-area offset while using align-center. | |||||
| expect(Math.abs((dialogBox!.y + dialogBox!.height / 2) - viewport!.height / 2)).toBeLessThanOrEqual(24) | |||||
| await dialogOverlay.click({ position: { x: 8, y: 8 } }) | |||||
| await expect(userDialog).toBeVisible() | |||||
| await captureScreenshot(page, testInfo, 'users-dialog-global-behavior') | |||||
| await userDialog.getByRole('button', { name: '取消' }).click() | |||||
| await expect(userDialog).toBeHidden() | |||||
| const pageSizeRequest = page.waitForRequest((request) => { | |||||
| const url = new URL(request.url()) | |||||
| return url.pathname.endsWith('/users') && url.searchParams.get('size') === '20' | |||||
| }) | |||||
| await page.locator('.el-pagination__sizes .el-select').click() | |||||
| await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '20' }).click() | |||||
| await pageSizeRequest | |||||
| await expect(page.locator('.el-pagination__sizes')).toContainText('20') | |||||
| const enabledUserRow = page.locator('.users-table .el-table__row').filter({ hasText: '周教员' }) | |||||
| await expect(enabledUserRow.getByRole('button', { name: '停用' })).toHaveClass(/el-button--warning/) | |||||
| const disabledUserRow = page.locator('.users-table .el-table__row').filter({ hasText: '李教员' }) | |||||
| await expect(disabledUserRow.getByRole('button', { name: '启用' })).toHaveClass(/el-button--success/) | |||||
| const departmentNode = page.locator('.department-filter-tree .el-tree-node__content').filter({ hasText: '维修改研室' }).first() | |||||
| await departmentNode.click() | |||||
| await expect(page.locator('.department-filter-state')).toContainText('已选择:维修改研室') | |||||
| await expect.poll(() => requestedDepartments.at(-1)).toBe('200') | |||||
| await expect(page.locator('.users-table .el-table__body-wrapper .el-table__row')).toHaveCount(2) | |||||
| await captureScreenshot(page, testInfo, 'users-department-selected') | |||||
| await departmentNode.click() | |||||
| await expect(page.locator('.department-filter-state')).toContainText('当前显示全部部门') | |||||
| await expect(page.locator('.department-filter-tree .el-tree-node.is-current')).toHaveCount(0) | |||||
| await expect.poll(() => requestedDepartments.at(-1)).toBeNull() | |||||
| await expect(page.locator('.users-table .el-table__body-wrapper .el-table__row')).toHaveCount(4) | |||||
| await captureScreenshot(page, testInfo, 'users-department-cleared') | |||||
| }) | |||||
| test('审计表格与分页遵循系统统一规范', async ({ page }, testInfo) => { | |||||
| const requestedAuditKeywords: Array<string | null> = [] | |||||
| await mockUserPageApi(page, [], [], requestedAuditKeywords) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('unreal-tran:web:access-token:v1', 'e2e-mock-token') | |||||
| }) | |||||
| await page.goto('/system/audit') | |||||
| await expect(page.getByRole('tab', { name: '审计日志', exact: true })).toBeVisible() | |||||
| const table = page.locator('.el-table') | |||||
| await expect(table).toHaveClass(/el-table--border/) | |||||
| await expect(table).toHaveClass(/el-table--striped/) | |||||
| await expect(page.locator('.system-pagination-total')).toHaveText('共 42 条记录') | |||||
| await expect(page.locator('.el-pagination__sizes')).toContainText('条/页') | |||||
| await expect(page.locator('.el-pagination__jump')).toContainText('前往') | |||||
| await expect(page.locator('.el-pagination .number')).toHaveCount(3) | |||||
| const rows = page.locator('.el-table__body-wrapper .el-table__row') | |||||
| await expect(rows.nth(0).locator('td').nth(2)).toContainText('系统管理') | |||||
| await expect(rows.nth(1).locator('td').nth(2)).toContainText('身份认证') | |||||
| await expect(rows.nth(2).locator('td').nth(2)).toContainText('内容制作') | |||||
| await expect(rows.nth(3).locator('td').nth(2)).toContainText('CUSTOM_AUDIT') | |||||
| const keyword = page.locator('.system-list-toolbar .el-input input').first() | |||||
| await keyword.fill('/system/config') | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| await expect.poll(() => requestedAuditKeywords.at(-1)).toBe('/system/config') | |||||
| await expect(rows).toHaveCount(20) | |||||
| await captureScreenshot(page, testInfo, 'audit-module-labels-and-uri-search') | |||||
| const auditCardIsUnified = await page.locator('.system-list-card').evaluate((card) => { | |||||
| const toolbar = card.querySelector('.system-list-toolbar') | |||||
| const body = card.querySelector('.system-list-body') | |||||
| return toolbar?.parentElement === card && body?.parentElement === card | |||||
| }) | |||||
| expect(auditCardIsUnified).toBe(true) | |||||
| const auditKeywordWidth = await page.locator('.system-list-toolbar .el-input').first() | |||||
| .evaluate((element) => element.getBoundingClientRect().width) | |||||
| expect(auditKeywordWidth).toBeLessThanOrEqual(391) | |||||
| await page.locator('.system-pagination').scrollIntoViewIfNeeded() | |||||
| await captureScreenshot(page, testInfo, 'audit-table-pagination') | |||||
| }) | |||||
| test('密码重置支持系统生成和手动输入并实时展示统一规则', async ({ page }, testInfo) => { | |||||
| const passwordResets: Array<Record<string, unknown>> = [] | |||||
| await mockUserPageApi(page, [], passwordResets) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('unreal-tran:web:access-token:v1', 'e2e-mock-token') | |||||
| }) | |||||
| await page.goto('/system/users') | |||||
| const row = page.locator('.users-table .el-table__row').filter({ hasText: '周教员' }) | |||||
| await row.getByRole('button', { name: '重置密码' }).click() | |||||
| const resetDialog = page.locator('.password-reset-dialog:visible') | |||||
| await expect(resetDialog).toBeVisible() | |||||
| await expect(resetDialog.getByRole('radio', { name: '系统重置' })).toBeChecked() | |||||
| await resetDialog.getByTestId('confirm-password-reset').click() | |||||
| const resultDialog = page.locator('.password-result-dialog:visible') | |||||
| await expect(resultDialog).toBeVisible() | |||||
| await expect(resultDialog.locator('input')).toHaveCount(0) | |||||
| await expect(resultDialog.getByTestId('password-result-mode')).toHaveText('系统生成') | |||||
| await expect(resultDialog.getByTestId('generated-password')).toHaveText('System#Reset8A') | |||||
| expect(passwordResets[0]).toEqual({ version: 1 }) | |||||
| await resultDialog.getByRole('button', { name: '我已妥善保存' }).click() | |||||
| await row.getByRole('button', { name: '重置密码' }).click() | |||||
| await resetDialog.locator('.el-radio-button').filter({ hasText: '手动重置' }).click() | |||||
| const passwordInput = resetDialog.getByTestId('manual-reset-password-input') | |||||
| const confirm = resetDialog.getByTestId('confirm-password-reset') | |||||
| await expect(resetDialog.locator('.password-requirements .is-unmet')).toHaveCount(5) | |||||
| await passwordInput.fill('weak') | |||||
| await expect(confirm).toBeDisabled() | |||||
| await passwordInput.fill('Manual#Reset8') | |||||
| await expect(resetDialog.locator('.password-requirements .is-met')).toHaveCount(5) | |||||
| await expect(confirm).toBeEnabled() | |||||
| await captureScreenshot(page, testInfo, 'password-reset-manual-requirements') | |||||
| await confirm.click() | |||||
| await expect(resetDialog).toBeHidden() | |||||
| expect(passwordResets[1]).toEqual({ password: 'Manual#Reset8', version: 1 }) | |||||
| await expect(resultDialog).toBeVisible() | |||||
| await expect(resultDialog.getByTestId('password-result-mode')).toHaveText('手动设置') | |||||
| await expect(resultDialog.getByTestId('generated-password')).toHaveText('Manual#Reset8') | |||||
| await captureScreenshot(page, testInfo, 'password-reset-manual-result') | |||||
| await resultDialog.getByRole('button', { name: '我已妥善保存' }).click() | |||||
| }) | |||||
| @@ -0,0 +1,152 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| test.use({ trace: 'off', video: 'off' }) | |||||
| const loginContext = { | |||||
| defaultRoleCode: 'admin', | |||||
| roles: [ | |||||
| { code: 'teacher', label: '教员', accountLabel: '教员账号', organizationMode: 'CASCADE', organizationLabels: ['教学系', '教研室'] }, | |||||
| { code: 'admin', label: '管理员', accountLabel: '管理员账号', organizationMode: 'NONE', organizationLabels: [] }, | |||||
| ], | |||||
| departments: [ | |||||
| { | |||||
| id: '100', code: 'TEST', name: '测试教学系', parentId: null, | |||||
| children: [{ id: '110', code: 'EMPTY', name: '空账号教研室', parentId: '100', children: [] }], | |||||
| }, | |||||
| ], | |||||
| } | |||||
| const forcedPasswordProfile = { | |||||
| user: { | |||||
| id: '7', username: 'teacher.test', displayName: '测试教员', | |||||
| departmentId: '110', departmentName: '空账号教研室', mustChangePassword: true, | |||||
| }, | |||||
| activeRoleId: '20', | |||||
| activeRole: { id: '20', code: 'teacher', name: '教员' }, | |||||
| roles: [{ id: '20', code: 'teacher', name: '教员', status: 1 }], | |||||
| permissions: [], | |||||
| authorizationMode: 'SINGLE_ACTIVE', | |||||
| mustChangePassword: true, | |||||
| } | |||||
| const envelope = (data: unknown, message = '成功', code = 200) => JSON.stringify({ | |||||
| code, message, data, timestamp: '2026-08-17T12:00:00+08:00', requestId: 'ute2e-zentao-auth', | |||||
| }) | |||||
| const fulfill = (route: Route, data: unknown, status = 200, message = '成功', code = 200) => route.fulfill({ | |||||
| status, | |||||
| contentType: 'application/json', | |||||
| body: envelope(data, message, code), | |||||
| }) | |||||
| async function mockAuth( | |||||
| page: Page, | |||||
| options: { me?: 'forced' | 'expired'; initialPassword401?: boolean } = {}, | |||||
| ) { | |||||
| let refreshRequests = 0 | |||||
| let initialPasswordRequests = 0 | |||||
| await page.route('**/api/auth/v1/**', async (route) => { | |||||
| const path = new URL(route.request().url()).pathname | |||||
| if (path.endsWith('/auth/login-context')) return fulfill(route, loginContext) | |||||
| if (path.endsWith('/auth/login-identities')) return fulfill(route, []) | |||||
| if (path.endsWith('/menus/navigation')) return fulfill(route, []) | |||||
| if (path.endsWith('/auth/me')) { | |||||
| if (options.me === 'expired') { | |||||
| return fulfill(route, null, 401, '内部会话详情不应展示', 40101) | |||||
| } | |||||
| return fulfill(route, forcedPasswordProfile) | |||||
| } | |||||
| if (path.endsWith('/auth/initial-password')) { | |||||
| initialPasswordRequests += 1 | |||||
| if (options.initialPassword401) { | |||||
| return fulfill(route, null, 401, '账号已停用:internal-user-id=7', 40102) | |||||
| } | |||||
| return fulfill(route, {}) | |||||
| } | |||||
| if (path.endsWith('/auth/refresh')) { | |||||
| refreshRequests += 1 | |||||
| return fulfill(route, null, 401, 'refresh-token-internal-detail', 40101) | |||||
| } | |||||
| return fulfill(route, {}) | |||||
| }) | |||||
| return { | |||||
| refreshRequests: () => refreshRequests, | |||||
| initialPasswordRequests: () => initialPasswordRequests, | |||||
| } | |||||
| } | |||||
| async function selectOption(page: Page, select: ReturnType<Page['locator']>, text: string) { | |||||
| await select.locator('.el-select__wrapper').click() | |||||
| await page.locator('.el-select-dropdown:visible').getByRole('option', { name: text, exact: true }).click() | |||||
| } | |||||
| async function seedSession(page: Page, includeRefresh = false) { | |||||
| await page.addInitScript(({ refresh }) => { | |||||
| if (sessionStorage.getItem('zentao-auth-seeded') === '1') return | |||||
| sessionStorage.setItem('zentao-auth-seeded', '1') | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', 'zentao-access-token') | |||||
| if (refresh) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', 'zentao-refresh-token') | |||||
| }, { refresh: includeRefresh }) | |||||
| } | |||||
| test('859:部门没有可登录教员时账号选择器明确禁用且不可展开', async ({ page }, testInfo) => { | |||||
| await mockAuth(page) | |||||
| await page.goto('/login') | |||||
| await page.locator('[data-role-code="teacher"]').click() | |||||
| await selectOption(page, page.getByTestId('login-organization'), '测试教学系') | |||||
| await selectOption(page, page.getByTestId('login-department'), '空账号教研室') | |||||
| const identity = page.getByTestId('login-identity') | |||||
| await expect(page.getByText('当前条件下暂无可登录教员账号,请联系管理员。')).toBeVisible() | |||||
| await expect(identity).toBeDisabled() | |||||
| await identity.locator('.el-select__wrapper').click({ force: true }) | |||||
| await expect(page.locator('.login-identity-popper:visible')).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, '859-empty-teacher-account-disabled') | |||||
| }) | |||||
| test('861:首次改密两次输入不一致时立即显示字段提示且不提交', async ({ page }, testInfo) => { | |||||
| await seedSession(page) | |||||
| const mocked = await mockAuth(page, { me: 'forced' }) | |||||
| await page.goto('/change-password') | |||||
| const inputs = page.locator('.password-card input[autocomplete="new-password"]') | |||||
| await inputs.nth(0).fill('Strong#Password8') | |||||
| await inputs.nth(1).fill('Different#Pass9') | |||||
| await expect(page.getByText('两次输入的新密码不一致', { exact: true })).toBeVisible() | |||||
| await expect(page.getByRole('button', { name: '确认修改并登录' })).toBeDisabled() | |||||
| expect(mocked.initialPasswordRequests()).toBe(0) | |||||
| await captureScreenshot(page, testInfo, '861-initial-password-mismatch') | |||||
| }) | |||||
| test('864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌', async ({ page }, testInfo) => { | |||||
| await seedSession(page, true) | |||||
| const mocked = await mockAuth(page, { me: 'forced', initialPassword401: true }) | |||||
| await page.goto('/change-password') | |||||
| const inputs = page.locator('.password-card input[autocomplete="new-password"]') | |||||
| await inputs.nth(0).fill('Strong#Password8') | |||||
| await inputs.nth(1).fill('Strong#Password8') | |||||
| await page.getByRole('button', { name: '确认修改并登录' }).click() | |||||
| await expect(page).toHaveURL(/\/login\?redirect=/) | |||||
| await expect(page.locator('#login-error')).toHaveText('账号、密码或登录身份不匹配') | |||||
| await expect(page.getByText(/internal-user-id|refresh-token-internal-detail/)).toHaveCount(0) | |||||
| expect(mocked.initialPasswordRequests()).toBe(1) | |||||
| expect(mocked.refreshRequests()).toBe(0) | |||||
| await captureScreenshot(page, testInfo, '864-disabled-account-one-time-notice') | |||||
| await page.reload() | |||||
| await expect(page.locator('#login-error')).toHaveText('') | |||||
| }) | |||||
| test('普通会话过期只跳转登录,不展示后端错误详情或停用账号提示', async ({ page }) => { | |||||
| await seedSession(page, true) | |||||
| const mocked = await mockAuth(page, { me: 'expired' }) | |||||
| await page.goto('/dashboard') | |||||
| await expect(page).toHaveURL(/\/login\?redirect=/) | |||||
| await expect(page.locator('#login-error')).toHaveText('') | |||||
| await expect(page.getByText(/内部会话详情|refresh-token-internal-detail|账号、密码或登录身份不匹配/)).toHaveCount(0) | |||||
| expect(mocked.refreshRequests()).toBe(1) | |||||
| }) | |||||
| @@ -0,0 +1,108 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const json = (route: Route, data: unknown) => route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ code: 0, message: '成功', data, timestamp: Date.now(), requestId: 'zentao-system-ui-regression' }), | |||||
| }) | |||||
| const roles = [ | |||||
| { | |||||
| id: '10', code: 'teacher', name: '教员', shortName: '教', description: '教员业务角色', enabled: true, | |||||
| builtIn: true, isSuperAdmin: false, dataScopeCode: 'SYSTEM', linkedUserCount: 3, permissionCount: 4, | |||||
| sortOrder: 10, version: 1, | |||||
| }, | |||||
| { | |||||
| id: '1', code: 'admin', name: '管理员', shortName: '管', description: '系统管理员', enabled: true, | |||||
| builtIn: true, isSuperAdmin: true, dataScopeCode: 'ALL', linkedUserCount: 1, permissionCount: 40, | |||||
| sortOrder: 20, version: 1, | |||||
| }, | |||||
| ] | |||||
| const departments = [{ | |||||
| id: '100', code: 'ORG-100', name: '某类装备维修实训数字车间', parentId: null, enabled: true, | |||||
| builtIn: true, isRoot: true, sortOrder: 10, directUserCount: 1, directChildCount: 1, | |||||
| descendantCount: 1, version: 1, updatedAt: '2026-08-16T10:00:00+08:00', | |||||
| children: [{ | |||||
| id: '110', code: 'ORG-110', name: '维修教研室', parentId: '100', enabled: true, | |||||
| builtIn: false, isRoot: false, sortOrder: 10, directUserCount: 3, directChildCount: 0, | |||||
| descendantCount: 0, version: 1, updatedAt: '2026-08-16T10:00:00+08:00', children: [], | |||||
| }], | |||||
| }] | |||||
| const permissionSections = Array.from({ length: 6 }, (_, sectionIndex) => ({ | |||||
| id: `section-${sectionIndex}`, | |||||
| label: `权限分组 ${sectionIndex + 1}`, | |||||
| items: Array.from({ length: 8 }, (_, itemIndex) => ({ | |||||
| code: `mock.section${sectionIndex}.action${itemIndex}`, | |||||
| label: `业务权限 ${sectionIndex + 1}-${itemIndex + 1}`, | |||||
| description: '用于验证长权限目录中的底部操作栏', | |||||
| type: 'ACTION', | |||||
| })), | |||||
| })) | |||||
| async function mockSystemApi(page: Page) { | |||||
| await page.route(/\/api\/(?:auth|tran)\//, async (route) => { | |||||
| const path = new URL(route.request().url()).pathname | |||||
| if (path.endsWith('/auth/me')) return json(route, { | |||||
| userId: '1', username: 'admin', displayName: '系统管理员', departmentId: '100', departmentName: '数字车间', | |||||
| activeRoleId: '1', roles: [roles[1]], authorizationMode: 'SINGLE_ACTIVE', mustChangePassword: false, | |||||
| permissions: ['system.departments', 'system.permissions', 'system.permissions.update'], | |||||
| }) | |||||
| if (path.endsWith('/departments/tree')) return json(route, departments) | |||||
| if (path.endsWith('/departments/stats')) return json(route, { total: 2, enabled: 2, maxDepth: 2, assignedUsers: 4 }) | |||||
| if (path.endsWith('/roles/all')) return json(route, roles) | |||||
| if (path.endsWith('/permissions/catalog')) return json(route, { sections: permissionSections }) | |||||
| if (/\/permissions\/roles\/\d+$/.test(path)) return json(route, { permissionCodes: [], version: 1 }) | |||||
| if (path.endsWith('/menus/navigation')) return json(route, []) | |||||
| if (path.endsWith('/system-config/public')) return json(route, { systemName: '某类装备维修实训数字车间' }) | |||||
| if (path.endsWith('/auth/ping')) return json(route, { service: 'ut-auth', status: 'UP' }) | |||||
| if (path.endsWith('/tran/ping')) return json(route, { service: 'ut-tran', status: 'UP' }) | |||||
| return json(route, {}) | |||||
| }) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('unreal-tran:web:access-token:v1', 'zentao-system-ui-regression-token') | |||||
| }) | |||||
| } | |||||
| test('Bug 850:无匹配部门时只保留工具栏重置入口', async ({ page }, testInfo) => { | |||||
| await mockSystemApi(page) | |||||
| await page.goto('/system/departments') | |||||
| await page.getByPlaceholder('请输入部门、编码或负责人').fill('绝对不存在的部门') | |||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| const empty = page.locator('.department-table-body .el-empty') | |||||
| await expect(empty).toBeVisible() | |||||
| await expect(empty).toContainText('没有符合当前筛选条件的部门') | |||||
| await expect(empty.getByRole('button', { name: '清除筛选' })).toHaveCount(0) | |||||
| await expect(page.locator('.department-toolbar').getByRole('button', { name: '重置', exact: true })).toBeVisible() | |||||
| await captureScreenshot(page, testInfo, 'bug-850-empty-without-duplicate-reset') | |||||
| }) | |||||
| test('Bug 853:权限操作栏在内容滚动过程中固定于可视区底部', async ({ page }, testInfo) => { | |||||
| await mockSystemApi(page) | |||||
| await page.setViewportSize({ width: 1440, height: 760 }) | |||||
| await page.goto('/system/permissions') | |||||
| const routeView = page.locator('.route-view') | |||||
| const footer = page.locator('.permission-actions') | |||||
| await expect(page.locator('.permission-group')).toHaveCount(permissionSections.length) | |||||
| await expect(footer).toBeVisible() | |||||
| await routeView.evaluate((element) => { element.scrollTop = Math.round(element.scrollHeight * 0.45) }) | |||||
| await expect.poll(() => routeView.evaluate((element) => element.scrollTop)).toBeGreaterThan(100) | |||||
| const [routeBox, footerBox, position] = await Promise.all([ | |||||
| routeView.boundingBox(), | |||||
| footer.boundingBox(), | |||||
| footer.evaluate((element) => getComputedStyle(element).position), | |||||
| ]) | |||||
| expect(routeBox).not.toBeNull() | |||||
| expect(footerBox).not.toBeNull() | |||||
| expect(position).toBe('sticky') | |||||
| expect(Math.abs((routeBox!.y + routeBox!.height) - (footerBox!.y + footerBox!.height))).toBeLessThanOrEqual(20) | |||||
| await captureScreenshot(page, testInfo, 'bug-853-sticky-permission-actions') | |||||
| }) | |||||
| @@ -0,0 +1,77 @@ | |||||
| import type { Page, Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| const json = (route: Route, data: unknown) => route.fulfill({ | |||||
| status: 200, | |||||
| contentType: 'application/json', | |||||
| body: JSON.stringify({ code: 0, message: '成功', data, timestamp: Date.now(), requestId: 'zentao-user-regression' }), | |||||
| }) | |||||
| const roles = [ | |||||
| { id: '1', code: 'admin', name: '管理员', shortName: '管', enabled: true, builtIn: true, isSuperAdmin: true, dataScopeCode: 'ALL', version: 1 }, | |||||
| { id: '2', code: 'teacher', name: '教员', shortName: '教', enabled: true, builtIn: true, isSuperAdmin: false, dataScopeCode: 'SYSTEM', version: 1 }, | |||||
| ] | |||||
| async function mockUsers(page: Page) { | |||||
| let userRequests = 0 | |||||
| let statsRequests = 0 | |||||
| await page.route(/\/api\/(?:auth|tran)\//, async (route) => { | |||||
| const path = new URL(route.request().url()).pathname | |||||
| if (path.endsWith('/auth/me')) return json(route, { | |||||
| userId: '1', username: 'admin', displayName: '系统管理员', departmentId: '100', departmentName: '数字车间', | |||||
| activeRoleId: '1', roles, authorizationMode: 'SINGLE_ACTIVE', mustChangePassword: false, | |||||
| permissions: ['system.users', 'system.users.update', 'system.users.reset-password'], | |||||
| }) | |||||
| if (path.endsWith('/departments/tree')) return json(route, [{ | |||||
| id: '100', code: 'ORG-100', name: '数字车间', parentId: null, enabled: true, isRoot: true, | |||||
| sortOrder: 10, directUserCount: 1, directChildCount: 0, version: 1, children: [], | |||||
| }]) | |||||
| if (path.endsWith('/roles/all')) return json(route, roles) | |||||
| if (path.endsWith('/users/stats')) { | |||||
| statsRequests += 1 | |||||
| const pending = statsRequests === 1 ? 2 : 1 | |||||
| return json(route, { total: 12, enabled: 12, disabled: 0, passwordPending: pending, mustChangePasswordCount: pending }) | |||||
| } | |||||
| if (path.endsWith('/users')) { | |||||
| userRequests += 1 | |||||
| return json(route, { | |||||
| records: [{ | |||||
| id: '9', username: 'teacher09', displayName: '测试教员', departmentId: '100', departmentName: '数字车间', | |||||
| roleIds: ['2', '1'], defaultRoleId: '1', enabled: true, isProtected: false, | |||||
| credentialConfigured: true, mustChangePassword: userRequests === 1, lastLoginAt: null, dataVersion: 1, | |||||
| }], | |||||
| total: 1, page: 1, size: 10, | |||||
| }) | |||||
| } | |||||
| if (path.endsWith('/menus/navigation')) return json(route, []) | |||||
| if (path.endsWith('/system-config/public')) return json(route, { systemName: '数字车间' }) | |||||
| return json(route, {}) | |||||
| }) | |||||
| await page.addInitScript(() => { | |||||
| window.sessionStorage.setItem('unreal-tran:web:access-token:v1', 'zentao-user-regression-token') | |||||
| }) | |||||
| return { | |||||
| statsRequests: () => statsRequests, | |||||
| } | |||||
| } | |||||
| test('Bug 860、862:默认角色置顶且搜索同步刷新全局待改密统计', async ({ page }, testInfo) => { | |||||
| const requests = await mockUsers(page) | |||||
| await page.goto('/system/users') | |||||
| const row = page.locator('.users-table .el-table__row').filter({ hasText: '测试教员' }) | |||||
| const roleTags = row.locator('.role-tags .el-tag') | |||||
| await expect(roleTags).toHaveCount(2) | |||||
| await expect(roleTags.first()).toContainText('管理员') | |||||
| await expect(roleTags.first()).toContainText('默认') | |||||
| await expect(page.getByTestId('user-inline-statistics')).toContainText('全局待改密 2') | |||||
| const searchInput = page.getByPlaceholder('请输入姓名、账号、部门或角色') | |||||
| await searchInput.fill('teacher09') | |||||
| await searchInput.press('Enter') | |||||
| await expect.poll(requests.statsRequests).toBe(2) | |||||
| await expect(page.getByTestId('user-inline-statistics')).toContainText('全局待改密 1') | |||||
| await expect(row.getByText('待修改密码', { exact: true })).toHaveCount(0) | |||||
| await captureScreenshot(page, testInfo, 'bugs-860-862-user-role-and-pending-statistics') | |||||
| }) | |||||
| @@ -0,0 +1,11 @@ | |||||
| { | |||||
| "compilerOptions": { | |||||
| "target": "ES2022", | |||||
| "module": "ESNext", | |||||
| "moduleResolution": "Bundler", | |||||
| "strict": true, | |||||
| "noEmit": true, | |||||
| "types": ["node", "@playwright/test"] | |||||
| }, | |||||
| "include": ["playwright.config.ts", "tests/**/*.ts"] | |||||
| } | |||||