Quellcode durchsuchen

chore: 初始化 UTE2E 测试工程

main
leiyun vor 4 Wochen
Commit
66f2eb4cae
69 geänderte Dateien mit 7444 neuen und 0 gelöschten Zeilen
  1. +21
    -0
      .env.example
  2. +12
    -0
      .gitignore
  3. +126
    -0
      README.md
  4. BIN
     
  5. BIN
     
  6. BIN
     
  7. BIN
     
  8. BIN
     
  9. BIN
     
  10. BIN
     
  11. BIN
     
  12. BIN
     
  13. BIN
     
  14. BIN
     
  15. BIN
     
  16. BIN
     
  17. BIN
     
  18. BIN
     
  19. BIN
     
  20. BIN
     
  21. +1
    -0
      artifacts/screenshots/.gitkeep
  22. BIN
     
  23. BIN
     
  24. BIN
     
  25. BIN
     
  26. BIN
     
  27. BIN
     
  28. +68
    -0
      artifacts/内容制作测试报告.md
  29. +61
    -0
      artifacts/内容制作测试报告模板.md
  30. +40
    -0
      artifacts/测试报告.md
  31. +72
    -0
      artifacts/菜单与内容升级测试报告.md
  32. +2
    -0
      mise.toml
  33. +111
    -0
      package-lock.json
  34. +19
    -0
      package.json
  35. +53
    -0
      playwright.config.ts
  36. +77
    -0
      pnpm-lock.yaml
  37. +39
    -0
      reports/Redis与全局界面规范测试报告.md
  38. +112
    -0
      reports/三维编辑器E2E测试报告.md
  39. +29
    -0
      reports/密码策略与重置E2E测试报告.md
  40. +133
    -0
      reports/教学实施模块测试报告.md
  41. +69
    -0
      reports/登录上下文UI契约测试报告.md
  42. +436
    -0
      tests/auth-login-contract.spec.ts
  43. +86
    -0
      tests/auth.spec.ts
  44. +644
    -0
      tests/content-authoring.spec.ts
  45. +158
    -0
      tests/content-editor-api.ts
  46. +367
    -0
      tests/content-guide-lifecycle.spec.ts
  47. +416
    -0
      tests/content-training-studio.spec.ts
  48. +148
    -0
      tests/filter-toolbar-layout.spec.ts
  49. +67
    -0
      tests/fixtures.ts
  50. +139
    -0
      tests/helpers.ts
  51. +316
    -0
      tests/login-prototype-visual.spec.ts
  52. +276
    -0
      tests/menu-content-navigation.spec.ts
  53. +204
    -0
      tests/platform-account.spec.ts
  54. +114
    -0
      tests/preferences.spec.ts
  55. +119
    -0
      tests/roles-form-regression.spec.ts
  56. +354
    -0
      tests/system-config.spec.ts
  57. +223
    -0
      tests/system-crud.spec.ts
  58. +93
    -0
      tests/system-inline-statistics.spec.ts
  59. +88
    -0
      tests/system-pages.spec.ts
  60. +681
    -0
      tests/teaching-contract-mock.ts
  61. +62
    -0
      tests/teaching-live.spec.ts
  62. +645
    -0
      tests/teaching-module.spec.ts
  63. +46
    -0
      tests/teaching-preview-workbench.spec.ts
  64. +68
    -0
      tests/teaching-runtime-p1.spec.ts
  65. +301
    -0
      tests/users-department-filter.spec.ts
  66. +152
    -0
      tests/zentao-auth-859-861-864.spec.ts
  67. +108
    -0
      tests/zentao-system-ui-regression.spec.ts
  68. +77
    -0
      tests/zentao-user-regression.spec.ts
  69. +11
    -0
      tsconfig.json

+ 21
- 0
.env.example Datei anzeigen

@@ -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=

+ 12
- 0
.gitignore Datei anzeigen

@@ -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/

+ 126
- 0
README.md Datei anzeigen

@@ -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)。


















+ 1
- 0
artifacts/screenshots/.gitkeep Datei anzeigen

@@ -0,0 +1 @@








+ 68
- 0
artifacts/内容制作测试报告.md Datei anzeigen

@@ -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。

+ 61
- 0
artifacts/内容制作测试报告模板.md Datei anzeigen

@@ -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、唯一编码和清理异常;附件不包含登录凭据。

+ 40
- 0
artifacts/测试报告.md Datei anzeigen

@@ -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 导出实现;本轮未停止或替换用户正在查看日志的进程。

+ 72
- 0
artifacts/菜单与内容升级测试报告.md Datei anzeigen

@@ -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 排版/签章/导出引擎应作为后续独立能力建设。

+ 2
- 0
mise.toml Datei anzeigen

@@ -0,0 +1,2 @@
[tools]
node = "22.19.0"

+ 111
- 0
package-lock.json Datei anzeigen

@@ -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"
}
}
}

+ 19
- 0
package.json Datei anzeigen

@@ -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"
}
}

+ 53
- 0
playwright.config.ts Datei anzeigen

@@ -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'] },
},
],
})

+ 77
- 0
pnpm-lock.yaml Datei anzeigen

@@ -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: {}

+ 39
- 0
reports/Redis与全局界面规范测试报告.md Datei anzeigen

@@ -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。

+ 112
- 0
reports/三维编辑器E2E测试报告.md Datei anzeigen

@@ -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。

+ 29
- 0
reports/密码策略与重置E2E测试报告.md Datei anzeigen

@@ -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 构建验证。

+ 133
- 0
reports/教学实施模块测试报告.md Datei anzeigen

@@ -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 通过结论写成“真实后端已联调通过”。

+ 69
- 0
reports/登录上下文UI契约测试报告.md Datei anzeigen

@@ -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 三档宽度,以及精简页头/账号菜单和个人中心资料/改密。

+ 436
- 0
tests/auth-login-contract.spec.ts Datei anzeigen

@@ -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}`)
})
}

+ 86
- 0
tests/auth.spec.ts Datei anzeigen

@@ -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')
})

+ 644
- 0
tests/content-authoring.spec.ts Datei anzeigen

@@ -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 未完成。',
)
}
}
})

+ 158
- 0
tests/content-editor-api.ts Datei anzeigen

@@ -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 调用异常' }
}
}

+ 367
- 0
tests/content-guide-lifecycle.spec.ts Datei anzeigen

@@ -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 })
})
})

+ 416
- 0
tests/content-training-studio.spec.ts Datei anzeigen

@@ -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([])
})
})

+ 148
- 0
tests/filter-toolbar-layout.spec.ts Datei anzeigen

@@ -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')
})

+ 67
- 0
tests/fixtures.ts Datei anzeigen

@@ -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 }

+ 139
- 0
tests/helpers.ts Datei anzeigen

@@ -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()

+ 316
- 0
tests/login-prototype-visual.spec.ts Datei anzeigen

@@ -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)
})
})

+ 276
- 0
tests/menu-content-navigation.spec.ts Datei anzeigen

@@ -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 })
}
})

+ 204
- 0
tests/platform-account.spec.ts Datei anzeigen

@@ -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)
})
})

+ 114
- 0
tests/preferences.spec.ts Datei anzeigen

@@ -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()
})

+ 119
- 0
tests/roles-form-regression.spec.ts Datei anzeigen

@@ -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: '' })
})

+ 354
- 0
tests/system-config.spec.ts Datei anzeigen

@@ -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')
})
})

+ 223
- 0
tests/system-crud.spec.ts Datei anzeigen

@@ -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 失败。',
)
}
}
})

+ 93
- 0
tests/system-inline-statistics.spec.ts Datei anzeigen

@@ -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)
}
})

+ 88
- 0
tests/system-pages.spec.ts Datei anzeigen

@@ -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)
})
}
})

+ 681
- 0
tests/teaching-contract-mock.ts Datei anzeigen

@@ -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',
}]
}
}

+ 62
- 0
tests/teaching-live.spec.ts Datei anzeigen

@@ -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-教学数据中心')
})
})

+ 645
- 0
tests/teaching-module.spec.ts Datei anzeigen

@@ -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')
})
})

+ 46
- 0
tests/teaching-preview-workbench.spec.ts Datei anzeigen

@@ -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$/)
})
})

+ 68
- 0
tests/teaching-runtime-p1.spec.ts Datei anzeigen

@@ -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()
})
})

+ 301
- 0
tests/users-department-filter.spec.ts Datei anzeigen

@@ -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()
})

+ 152
- 0
tests/zentao-auth-859-861-864.spec.ts Datei anzeigen

@@ -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)
})

+ 108
- 0
tests/zentao-system-ui-regression.spec.ts Datei anzeigen

@@ -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')
})

+ 77
- 0
tests/zentao-user-regression.spec.ts Datei anzeigen

@@ -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')
})

+ 11
- 0
tsconfig.json Datei anzeigen

@@ -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"]
}

Laden…
Abbrechen
Speichern