commit 66f2eb4caef5779102a6de18de001fddefcf016e Author: leiyun Date: Sat Aug 22 11:15:56 2026 +0800 chore: 初始化 UTE2E 测试工程 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b5c1419 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..937e22a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebf2c32 --- /dev/null +++ b/README.md @@ -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)。 diff --git a/artifacts/final-smoke/login-admin-default.png b/artifacts/final-smoke/login-admin-default.png new file mode 100644 index 0000000..179ffc2 Binary files /dev/null and b/artifacts/final-smoke/login-admin-default.png differ diff --git a/artifacts/final-smoke/login-page.png b/artifacts/final-smoke/login-page.png new file mode 100644 index 0000000..49f4383 Binary files /dev/null and b/artifacts/final-smoke/login-page.png differ diff --git a/artifacts/final-smoke/model-editor-dark-1920.png b/artifacts/final-smoke/model-editor-dark-1920.png new file mode 100644 index 0000000..ac3b442 Binary files /dev/null and b/artifacts/final-smoke/model-editor-dark-1920.png differ diff --git a/artifacts/final-smoke/model-editor-light-1440.png b/artifacts/final-smoke/model-editor-light-1440.png new file mode 100644 index 0000000..52dfb70 Binary files /dev/null and b/artifacts/final-smoke/model-editor-light-1440.png differ diff --git a/artifacts/final-smoke/scene-editor-lan-loaded.png b/artifacts/final-smoke/scene-editor-lan-loaded.png new file mode 100644 index 0000000..eda89d7 Binary files /dev/null and b/artifacts/final-smoke/scene-editor-lan-loaded.png differ diff --git a/artifacts/prototype-audit/01-training.png b/artifacts/prototype-audit/01-training.png new file mode 100644 index 0000000..dfd9c15 Binary files /dev/null and b/artifacts/prototype-audit/01-training.png differ diff --git a/artifacts/prototype-audit/02-teacher-preview.png b/artifacts/prototype-audit/02-teacher-preview.png new file mode 100644 index 0000000..5f2cc55 Binary files /dev/null and b/artifacts/prototype-audit/02-teacher-preview.png differ diff --git a/artifacts/prototype-audit/03-records.png b/artifacts/prototype-audit/03-records.png new file mode 100644 index 0000000..1b72fef Binary files /dev/null and b/artifacts/prototype-audit/03-records.png differ diff --git a/artifacts/prototype-audit/04-guides.png b/artifacts/prototype-audit/04-guides.png new file mode 100644 index 0000000..8ed938c Binary files /dev/null and b/artifacts/prototype-audit/04-guides.png differ diff --git a/artifacts/prototype-audit/05-physical.png b/artifacts/prototype-audit/05-physical.png new file mode 100644 index 0000000..aefe6b8 Binary files /dev/null and b/artifacts/prototype-audit/05-physical.png differ diff --git a/artifacts/prototype-audit/06-confrontation.png b/artifacts/prototype-audit/06-confrontation.png new file mode 100644 index 0000000..ea06d7e Binary files /dev/null and b/artifacts/prototype-audit/06-confrontation.png differ diff --git a/artifacts/prototype-audit/07-exams.png b/artifacts/prototype-audit/07-exams.png new file mode 100644 index 0000000..d0c8120 Binary files /dev/null and b/artifacts/prototype-audit/07-exams.png differ diff --git a/artifacts/prototype-audit/08-immersive.png b/artifacts/prototype-audit/08-immersive.png new file mode 100644 index 0000000..f417dac Binary files /dev/null and b/artifacts/prototype-audit/08-immersive.png differ diff --git a/artifacts/prototype-audit/09-data-center.png b/artifacts/prototype-audit/09-data-center.png new file mode 100644 index 0000000..f859b35 Binary files /dev/null and b/artifacts/prototype-audit/09-data-center.png differ diff --git a/artifacts/prototype-audit/10-physical-preview.png b/artifacts/prototype-audit/10-physical-preview.png new file mode 100644 index 0000000..5fc5739 Binary files /dev/null and b/artifacts/prototype-audit/10-physical-preview.png differ diff --git a/artifacts/prototype-teacher-preview.png b/artifacts/prototype-teacher-preview.png new file mode 100644 index 0000000..69a8313 Binary files /dev/null and b/artifacts/prototype-teacher-preview.png differ diff --git a/artifacts/prototype-training-list.png b/artifacts/prototype-training-list.png new file mode 100644 index 0000000..d4cd7f2 Binary files /dev/null and b/artifacts/prototype-training-list.png differ diff --git a/artifacts/screenshots/.gitkeep b/artifacts/screenshots/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/artifacts/screenshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/artifacts/screenshots/login-prototype-migration/login-desktop-final.png b/artifacts/screenshots/login-prototype-migration/login-desktop-final.png new file mode 100644 index 0000000..27a73ba Binary files /dev/null and b/artifacts/screenshots/login-prototype-migration/login-desktop-final.png differ diff --git a/artifacts/screenshots/login-prototype-migration/login-desktop-initial.png b/artifacts/screenshots/login-prototype-migration/login-desktop-initial.png new file mode 100644 index 0000000..7f95af3 Binary files /dev/null and b/artifacts/screenshots/login-prototype-migration/login-desktop-initial.png differ diff --git a/artifacts/screenshots/login-prototype-migration/login-desktop-r2.png b/artifacts/screenshots/login-prototype-migration/login-desktop-r2.png new file mode 100644 index 0000000..12da522 Binary files /dev/null and b/artifacts/screenshots/login-prototype-migration/login-desktop-r2.png differ diff --git a/artifacts/screenshots/login-prototype-migration/login-mobile-final.png b/artifacts/screenshots/login-prototype-migration/login-mobile-final.png new file mode 100644 index 0000000..35ebb5f Binary files /dev/null and b/artifacts/screenshots/login-prototype-migration/login-mobile-final.png differ diff --git a/artifacts/screenshots/login-prototype-migration/login-mobile-initial.png b/artifacts/screenshots/login-prototype-migration/login-mobile-initial.png new file mode 100644 index 0000000..8223fff Binary files /dev/null and b/artifacts/screenshots/login-prototype-migration/login-mobile-initial.png differ diff --git a/artifacts/screenshots/login-prototype-migration/login-mobile-r2.png b/artifacts/screenshots/login-prototype-migration/login-mobile-r2.png new file mode 100644 index 0000000..10d71a3 Binary files /dev/null and b/artifacts/screenshots/login-prototype-migration/login-mobile-r2.png differ diff --git a/artifacts/内容制作测试报告.md b/artifacts/内容制作测试报告.md new file mode 100644 index 0000000..3bad971 --- /dev/null +++ b/artifacts/内容制作测试报告.md @@ -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。 diff --git a/artifacts/内容制作测试报告模板.md b/artifacts/内容制作测试报告模板.md new file mode 100644 index 0000000..fa3cc26 --- /dev/null +++ b/artifacts/内容制作测试报告模板.md @@ -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、唯一编码和清理异常;附件不包含登录凭据。 diff --git a/artifacts/测试报告.md b/artifacts/测试报告.md new file mode 100644 index 0000000..52ce6c3 --- /dev/null +++ b/artifacts/测试报告.md @@ -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 导出实现;本轮未停止或替换用户正在查看日志的进程。 diff --git a/artifacts/菜单与内容升级测试报告.md b/artifacts/菜单与内容升级测试报告.md new file mode 100644 index 0000000..9362a15 --- /dev/null +++ b/artifacts/菜单与内容升级测试报告.md @@ -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 排版/签章/导出引擎应作为后续独立能力建设。 diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..7175f7b --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "22.19.0" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3899179 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e11b1ea --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4e73a54 --- /dev/null +++ b/playwright.config.ts @@ -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'] }, + }, + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ed10230 --- /dev/null +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/reports/Redis与全局界面规范测试报告.md b/reports/Redis与全局界面规范测试报告.md new file mode 100644 index 0000000..8e9e031 --- /dev/null +++ b/reports/Redis与全局界面规范测试报告.md @@ -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。 diff --git a/reports/三维编辑器E2E测试报告.md b/reports/三维编辑器E2E测试报告.md new file mode 100644 index 0000000..0a26332 --- /dev/null +++ b/reports/三维编辑器E2E测试报告.md @@ -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。 diff --git a/reports/密码策略与重置E2E测试报告.md b/reports/密码策略与重置E2E测试报告.md new file mode 100644 index 0000000..6ae5359 --- /dev/null +++ b/reports/密码策略与重置E2E测试报告.md @@ -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 构建验证。 diff --git a/reports/教学实施模块测试报告.md b/reports/教学实施模块测试报告.md new file mode 100644 index 0000000..3c52434 --- /dev/null +++ b/reports/教学实施模块测试报告.md @@ -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 通过结论写成“真实后端已联调通过”。 diff --git a/reports/登录上下文UI契约测试报告.md b/reports/登录上下文UI契约测试报告.md new file mode 100644 index 0000000..5ce1640 --- /dev/null +++ b/reports/登录上下文UI契约测试报告.md @@ -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 三档宽度,以及精简页头/账号菜单和个人中心资料/改密。 diff --git a/tests/auth-login-contract.spec.ts b/tests/auth-login-contract.spec.ts new file mode 100644 index 0000000..e3fa6b8 --- /dev/null +++ b/tests/auth-login-contract.spec.ts @@ -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> = { + '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('.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}`) + }) +} diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts new file mode 100644 index 0000000..1456f80 --- /dev/null +++ b/tests/auth.spec.ts @@ -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') +}) diff --git a/tests/content-authoring.spec.ts b/tests/content-authoring.spec.ts new file mode 100644 index 0000000..82e3b61 --- /dev/null +++ b/tests/content-authoring.spec.ts @@ -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 { + data?: T +} + +interface UploadedAssetCommand { + code: string + name: string + type: string + storageUri: string + mimeType: string + sizeBytes: number + sha256: string + metadata: Record + status: string + sortOrder: number + uploadTicket?: string +} + +interface ModelDocument { + schema?: string + version?: number + modelResource?: { assetCode?: string; storageUri?: string; sha256?: string } + model?: { objects?: Record } + 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, operation: string): Promise { + const response = await responsePromise + expect(response.ok(), `${operation}应返回成功状态,实际 HTTP ${response.status()}`).toBeTruthy() + return response +} + +async function waitForContentList(page: Page): Promise { + 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 { + 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 + 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 { + 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 { + 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 { + 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 { + 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 { + 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((resolve) => { releaseResponse = resolve }) + const serverStored = new Promise((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 + 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[0], + session: ContentApiSession, + projectId: string, + detail: ContentDetailRecord, + uploaded: UploadedAssetCommand, +): Promise { + 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 未完成。', + ) + } + } +}) diff --git a/tests/content-editor-api.ts b/tests/content-editor-api.ts new file mode 100644 index 0000000..b627594 --- /dev/null +++ b/tests/content-editor-api.ts @@ -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 + 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 + dependencies: ContentDependencyRecord[] + assets: ContentAssetRecord[] + } +} + +interface ApiEnvelope { + data?: T +} + +export interface ContentApiSession { + headers: Record +} + +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 (response: Awaited>, operation: string): Promise => { + if (!response.ok()) throw new Error(`${operation}失败(HTTP ${response.status()})`) + const body = await response.json() as ApiEnvelope + if (body.data === undefined) throw new Error(`${operation}失败:响应未包含 data`) + return body.data +} + +export async function openContentApiSession(request: APIRequestContext): Promise { + 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 { + 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 { + const response = await request.get(endpoint(projectId), { headers: session.headers }) + return readData(response, '读取内容工程') +} + +export async function removeContentProject( + request: APIRequestContext, + session: ContentApiSession, + kind: CleanupRecord['kind'], + projectId: string | null, + code: string, +): Promise { + 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 + 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 调用异常' } + } +} diff --git a/tests/content-guide-lifecycle.spec.ts b/tests/content-guide-lifecycle.spec.ts new file mode 100644 index 0000000..60d7768 --- /dev/null +++ b/tests/content-guide-lifecycle.spec.ts @@ -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 + +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 = { + 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('')).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 }) + }) +}) diff --git a/tests/content-training-studio.spec.ts b/tests/content-training-studio.spec.ts new file mode 100644 index 0000000..e500a2e --- /dev/null +++ b/tests/content-training-studio.spec.ts @@ -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 + +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 + 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([]) + }) +}) diff --git a/tests/filter-toolbar-layout.spec.ts b/tests/filter-toolbar-layout.spec.ts new file mode 100644 index 0000000..6fdd1d5 --- /dev/null +++ b/tests/filter-toolbar-layout.spec.ts @@ -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') +}) diff --git a/tests/fixtures.ts b/tests/fixtures.ts new file mode 100644 index 0000000..2d95e39 --- /dev/null +++ b/tests/fixtures.ts @@ -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 } diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..489d5e7 --- /dev/null +++ b/tests/helpers.ts @@ -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() diff --git a/tests/login-prototype-visual.spec.ts b/tests/login-prototype-visual.spec.ts new file mode 100644 index 0000000..bf70b61 --- /dev/null +++ b/tests/login-prototype-visual.spec.ts @@ -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('.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) + }) +}) diff --git a/tests/menu-content-navigation.spec.ts b/tests/menu-content-navigation.spec.ts new file mode 100644 index 0000000..1c41a06 --- /dev/null +++ b/tests/menu-content-navigation.spec.ts @@ -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) => 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 }) + } +}) diff --git a/tests/platform-account.spec.ts b/tests/platform-account.spec.ts new file mode 100644 index 0000000..acf3a35 --- /dev/null +++ b/tests/platform-account.spec.ts @@ -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 | null = null + await page.route('**/api/auth/v1/auth/password', async (route) => { + passwordRequest = route.request().postDataJSON() as Record + 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 | null = null + await page.route('**/api/auth/v1/auth/initial-password', async (route) => { + passwordRequest = route.request().postDataJSON() as Record + 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) + }) +}) diff --git a/tests/preferences.spec.ts b/tests/preferences.spec.ts new file mode 100644 index 0000000..9d4aefb --- /dev/null +++ b/tests/preferences.spec.ts @@ -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() +}) diff --git a/tests/roles-form-regression.spec.ts b/tests/roles-form-regression.spec.ts new file mode 100644 index 0000000..54ef7d9 --- /dev/null +++ b/tests/roles-form-regression.spec.ts @@ -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>) { + 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 + 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 + 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> = [] + 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: '' }) +}) diff --git a/tests/system-config.spec.ts b/tests/system-config.spec.ts new file mode 100644 index 0000000..3b6c6b6 --- /dev/null +++ b/tests/system-config.spec.ts @@ -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') + }) +}) diff --git a/tests/system-crud.spec.ts b/tests/system-crud.spec.ts new file mode 100644 index 0000000..10b49d6 --- /dev/null +++ b/tests/system-crud.spec.ts @@ -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 失败。', + ) + } + } +}) diff --git a/tests/system-inline-statistics.spec.ts b/tests/system-inline-statistics.spec.ts new file mode 100644 index 0000000..c4d1dcf --- /dev/null +++ b/tests/system-inline-statistics.spec.ts @@ -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) + } +}) diff --git a/tests/system-pages.spec.ts b/tests/system-pages.spec.ts new file mode 100644 index 0000000..00f7dc6 --- /dev/null +++ b/tests/system-pages.spec.ts @@ -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) + }) + } +}) diff --git a/tests/teaching-contract-mock.ts b/tests/teaching-contract-mock.ts new file mode 100644 index 0000000..cd2a1ae --- /dev/null +++ b/tests/teaching-contract-mock.ts @@ -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 +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 = { + 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() + readonly runs = new Map() + readonly events = new Map() + readonly interventions: JsonRecord[] = [] + readonly deniedPermissions = new Set() + 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', + }] + } +} diff --git a/tests/teaching-live.spec.ts b/tests/teaching-live.spec.ts new file mode 100644 index 0000000..73c2072 --- /dev/null +++ b/tests/teaching-live.spec.ts @@ -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-教学数据中心') + }) +}) diff --git a/tests/teaching-module.spec.ts b/tests/teaching-module.spec.ts new file mode 100644 index 0000000..0f0714a --- /dev/null +++ b/tests/teaching-module.spec.ts @@ -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[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 }) + 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> } + 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') + }) +}) diff --git a/tests/teaching-preview-workbench.spec.ts b/tests/teaching-preview-workbench.spec.ts new file mode 100644 index 0000000..760804f --- /dev/null +++ b/tests/teaching-preview-workbench.spec.ts @@ -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$/) + }) +}) diff --git a/tests/teaching-runtime-p1.spec.ts b/tests/teaching-runtime-p1.spec.ts new file mode 100644 index 0000000..94b23a7 --- /dev/null +++ b/tests/teaching-runtime-p1.spec.ts @@ -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 + const definition = task.definitionSnapshot as { steps: Array>; modeConfig: Record } + 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 + const members = runtime.members as Array> + 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() + }) +}) diff --git a/tests/users-department-filter.spec.ts b/tests/users-department-filter.spec.ts new file mode 100644 index 0000000..ede6dc4 --- /dev/null +++ b/tests/users-department-filter.spec.ts @@ -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, + passwordResets: Array> = [], + requestedAuditKeywords: Array = [], +) { + 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 + 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 = [] + 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 = [] + 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> = [] + 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() +}) diff --git a/tests/zentao-auth-859-861-864.spec.ts b/tests/zentao-auth-859-861-864.spec.ts new file mode 100644 index 0000000..8a69282 --- /dev/null +++ b/tests/zentao-auth-859-861-864.spec.ts @@ -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, 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) +}) diff --git a/tests/zentao-system-ui-regression.spec.ts b/tests/zentao-system-ui-regression.spec.ts new file mode 100644 index 0000000..ce9ce38 --- /dev/null +++ b/tests/zentao-system-ui-regression.spec.ts @@ -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') +}) diff --git a/tests/zentao-user-regression.spec.ts b/tests/zentao-user-regression.spec.ts new file mode 100644 index 0000000..e38d240 --- /dev/null +++ b/tests/zentao-user-regression.spec.ts @@ -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') +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d593484 --- /dev/null +++ b/tsconfig.json @@ -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"] +}