收紧 React 测试契约
将画布相关 hook 测试改为直接验证公开返回值 移除组件测试中对 class、图标和测试专用 DOM 状态的脆弱断言 新增 React 组件测试准则并同步项目记忆
This commit is contained in:
@@ -34,6 +34,8 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
|
||||
|
||||
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
|
||||
|
||||
React 组件测试的用户行为、稳定契约、hook / model 分层断言口径,以及避免内部 DOM 探针、图标 class 和完整对象快照式断言的规则见 [【前端测试】React组件测试准则-2026-06-26.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%B5%8B%E8%AF%95%E3%80%91React%E7%BB%84%E4%BB%B6%E6%B5%8B%E8%AF%95%E5%87%86%E5%88%99-2026-06-26.md)。
|
||||
|
||||
桌面端全站客服悬浮入口的全局接入点、固定层级、拖拽边界和移动端隐藏口径见 [【前端架构】全站客服悬浮入口接入约定-2026-06-23.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%85%A8%E7%AB%99%E5%AE%A2%E6%9C%8D%E6%82%AC%E6%B5%AE%E5%85%A5%E5%8F%A3%E6%8E%A5%E5%85%A5%E7%BA%A6%E5%AE%9A-2026-06-23.md)。
|
||||
|
||||
桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-26 React 组件测试按用户行为与稳定契约收敛
|
||||
|
||||
- 背景:部分 React 测试把组件内部状态、测试专用 DOM 探针、图标 class、完整按钮顺序或精确长文案当成契约,正常 UI 重构时容易误报,增加维护成本。
|
||||
- 决策:新增和重写 React 测试时,默认分成用户流程测试、稳定契约测试、hook / model 逻辑测试三层。用户流程测试优先断言 role / label / URL / 弹窗 / callback 等可感知结果;演化中的 DTO 和 callback payload 使用关键字段或 `expect.objectContaining(...)`;hook 测试使用 `renderHook` 验证公开返回契约,不再为读取内部状态制造 `data-testid` 仪表盘。
|
||||
- 影响范围:前端 React 组件测试、图片画布测试、平台入口测试、后续共享组件和 hook 测试新增 / 重写方式。
|
||||
- 验证方式:运行定向 React 测试、`npm run typecheck`、`npm run check:encoding` 和 `git diff --check`;出现正常重构引发测试破碎时,优先把测试改到用户行为或稳定契约层。
|
||||
- 关联文档:`docs/technical/【前端测试】React组件测试准则-2026-06-26.md`、`src/components/image-editor/useCanvasGenerationDialogs.test.tsx`、`src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`。
|
||||
|
||||
## 2026-06-24 外部生成队列升级为正式生成任务列表
|
||||
|
||||
- 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。
|
||||
@@ -148,7 +156,7 @@
|
||||
## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界
|
||||
|
||||
- 背景:`/editor/canvas` 生成视频需要严格对齐火山 Seedance 2.0 多模态参考输入;参考视频若继续走 Base64 / `data:video` 会超过请求体并被上游拒绝,参考音频单独输入和非 Seedance 模型携带参考字段也会违反文档契约。
|
||||
- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~9、视频 0~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset_object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference_*` role 构造,并显式发送 `generate_audio:false`。
|
||||
- 决策:仅 `seedance2.0-fast` / `seedance2.0` 可提交参考图片、参考视频、参考音频;图片 0~9、视频 0~3、音频 0~3,音频必须搭配图片或视频。参考视频只能提交公网 URL、`asset://` 或画板资源 `objectKey`,禁止 `data:video/*`;视频 / 音频上传先走 OSS 直传和 asset*object confirm,前端保存 signed URL 预览但提交优先 `objectKey`,后端统一重新签名给 Ark。Ark body 按 `image_url` / `video_url` / `audio_url` + `reference*\*`role 构造,并显式发送`generate_audio:false`。
|
||||
- 影响范围:图片画布生成视频面板、参考媒体上传工作流、`editorReferenceUploadClient`、`ImageCanvasGenerationSubmissionModel`、`shared-contracts`、`api-server` 编辑器视频 BFF、Lovart 生成类面板文档。
|
||||
- 验证方式:运行 `npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、火山 Seedance 2.0 任务创建文档。
|
||||
@@ -570,6 +578,7 @@
|
||||
- 影响范围:`src/components/common/PublishShareModal.tsx`、`src/components/common/publishShareModalModel.ts`、`src/components/common/publishShareCardImage.ts`、`src/services/wechatMiniProgramShareGrid.ts`、`src/services/wechatMiniProgramShareTarget.ts`、`miniprogram/pages/web-view/`、`miniprogram/pages/share-grid/`、推荐页 runtime CSS 和平台玩法链路文档。
|
||||
- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx miniprogram/pages/web-view/index.test.js src/services/wechatMiniProgramShareTarget.test.ts`、`npm run test -- miniprogram/pages/share-grid/index.test.js`、`npm run test -- src/index.test.ts -t "mini program recommend runtime"`、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-08 微信能力按领域收口
|
||||
|
||||
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。
|
||||
@@ -2766,6 +2775,7 @@
|
||||
- 决策补充:Pingora release readiness 分为源码全量门禁和 current release runtime-only 门禁。源码 checkout / CI / 构建环境继续运行默认 `check-pingora-release-readiness.mjs`,覆盖 Cargo、npm、Docker、Nginx 静态 / 真机校验和发布包构建烟测;目标机 `/opt/genarrative/current` 的启用前基础门禁和启用后 `--require-direct` 复核必须调用随包 `scripts/check-pingora-release-readiness.mjs --release-runtime-only`,只执行 current release 自审、启用前直连彩排状态、live canary、access log 对账、direct preflight、health patrol env 复核和 direct live smoke。未带 `--require-direct` 时 runtime-only 必须自动运行随包 `scripts/ops/pingora-direct-rehearsal-status.mjs --expect-public-gateway nginx --require-pingora-shadow --require-realpath-canary --require-current-release-gateway --fail-on-critical`,确认公网 `80/443` 仍由 Nginx 接流、Pingora shadow `127.0.0.1:18081`、realpath canary `127.0.0.1:18083` 和 current release 自审均通过;启用后 `--require-direct` 复核不再要求 Nginx 接公网彩排状态,改为检查 direct preflight、health patrol 直连模式和 direct live smoke。API release / current release 必须随包携带 `scripts/check-pingora-release-readiness.mjs`、`scripts/check-pingora-canary-live.mjs`、canary access log 对账脚本、直连彩排状态脚本和 direct preflight / live 子脚本;runtime-only 模式不得依赖源码 checkout、npm project root、Docker 或目标机 Nginx 静态校验。
|
||||
- 决策补充:Pingora 直连静态响应必须显式写入缓存头。HTML、目录 index 和 SPA fallback 默认 `Cache-Control: no-cache`;`/assets/*` 与 `/admin/assets/*` 中带 Vite 指纹文件名的资源默认 `Cache-Control: public, max-age=31536000, immutable`;非指纹静态和 ACME challenge 默认 `no-cache`。三档由 `GENARRATIVE_PINGORA_GATEWAY_HTML_CACHE_CONTROL`、`GENARRATIVE_PINGORA_GATEWAY_ASSET_CACHE_CONTROL` 和 `GENARRATIVE_PINGORA_GATEWAY_STATIC_CACHE_CONTROL` 覆盖,配置值不能包含换行或 NUL;`npm run check:pingora-gateway-smoke` 必须覆盖这些缓存头,避免直连后入口 HTML 被长期缓存或指纹资源失去长期缓存收益。
|
||||
- 决策补充:Pingora 直连同一公网 IP 上的多域名时,必须先保住非主站域名的 Host 语义。dev 上 `dev.genarrative.world` 与 `git.genarrative.world` 共用 `80/443`,因此 direct env 必须配置 `GENARRATIVE_PINGORA_GATEWAY_GITEA_HOSTS=git.genarrative.world` 和 `GENARRATIVE_PINGORA_GATEWAY_GITEA_UPSTREAM=127.0.0.1:3000`,命中 Gitea Host 的请求整站代理到 Gitea,且不走应用维护页、API body limit 或网关接流保护。当前 Pingora TLS listener 只加载一组 cert/key;同时接管 `dev.genarrative.world` 和 `git.genarrative.world` 前,证书必须覆盖两个域名,不能使用单域名 SAN 证书。
|
||||
|
||||
## 2026-06-11 资产计费边界改为 fail-closed 并补偿退款
|
||||
|
||||
- 背景:图片 / 资产生成入口曾在钱包或 SpacetimeDB 预扣费连通性异常时允许继续生成,且失败后同步退款如果遇到 SpacetimeDB 短暂不可用缺少本地补偿;拼图首图后台任务还使用 api-server 进程内 HashSet 互斥,多实例下不能防重复。
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
## 记录格式
|
||||
|
||||
```md
|
||||
|
||||
## 问题标题
|
||||
|
||||
- 现象:看到什么错误或异常行为
|
||||
@@ -15,6 +14,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## React 测试因内部状态或实现细节正常重构就碎
|
||||
|
||||
- 现象:修改组件结构、按钮排序、图标库 class、提示文案或 hook 内部状态名后,React 测试大量失败,但真实用户流程和对外契约没有变化。
|
||||
- 原因:测试把 `data-testid` 仪表盘、`textContent` 拼接状态、完整对象 / 数组顺序、图标 class 或长文案当成契约;这些断言绑定的是实现形状,不是用户行为或稳定边界。
|
||||
- 处理:按 `React 组件测试准则` 重写到更稳定的层级。用户流程测试断言 role / label / URL / 弹窗 / callback;hook 逻辑用 `renderHook` 直接验证公开返回契约;DTO / payload 使用关键字段或 `expect.objectContaining(...)`。只有产品明确要求的可访问语义、固定顺序或渲染边界才保留精确断言。
|
||||
- 验证:运行触达文件的定向 `vitest`,必要时追加 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联:`docs/technical/【前端测试】React组件测试准则-2026-06-26.md`、`src/components/image-editor/useCanvasGenerationDialogs.test.tsx`、`src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`。
|
||||
|
||||
## 图片画布素材库删除要匹配 sourceResourceId
|
||||
|
||||
- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。
|
||||
@@ -302,6 +309,7 @@
|
||||
- 处理:Windows 本地 dev 脚本应把 `RUSTC_WRAPPER` 和 `CARGO_BUILD_RUSTC_WRAPPER` 显式设为空字符串,让 Cargo 覆盖项目配置并直连真实 rustc;Linux 保持 `/usr/bin/env` 绕过 sccache。
|
||||
- 验证:`npm run test -- scripts/dev.test.ts -t "Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache"`,并用 `npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。
|
||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Pingora 直连 80/443 不能只改 env
|
||||
|
||||
- 现象:`/etc/genarrative/pingora-gateway.env` 已把 `GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN` / `HTTP_REDIRECT_LISTEN` 改到 `0.0.0.0:443` / `0.0.0.0:80`,但 `genarrative-pingora-gateway.service` 启动失败,日志出现低端口绑定权限错误。
|
||||
@@ -1586,7 +1594,6 @@
|
||||
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
|
||||
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
|
||||
|
||||
|
||||
## 本地短信 smoke 先确认 SMS provider
|
||||
|
||||
- 现象:浏览器里短信验证码发送成功,但提交 `123456` 仍然报验证码错误,或者短信登录后又回到未登录态。
|
||||
@@ -1594,6 +1601,7 @@
|
||||
- 处理:本地只做 UI / 账号链路 smoke 时,把 `.env.local` 显式设为 `SMS_AUTH_PROVIDER=mock` 且配置 `SMS_AUTH_MOCK_VERIFY_CODE=123456`,然后重启 `npm run dev` 或 `npm run dev:api-server`。要做真实短信联调时,再切回 `SMS_AUTH_PROVIDER=aliyun` 并重启。
|
||||
- 验证:`POST /api/auth/phone/send-code` 应返回 `providerRequestId=mock-request-id`;`POST /api/auth/phone/login` 用 `123456` 应返回 `200` 且 `user.loginMethod=phone`。浏览器侧短信登录成功后,会先进入邀请码弹窗或我的页面,不应再提示“验证码错误”。
|
||||
- 关联:`scripts/dev-utils.mjs`、`scripts/dev-utils.test.ts`、`scripts/dev.mjs`、`server-rs/crates/api-server/src/config.rs`。
|
||||
|
||||
## 手机验证码登录成功后又瞬间回到未登录
|
||||
|
||||
- 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。
|
||||
@@ -1991,9 +1999,9 @@
|
||||
|
||||
## 抓大鹅难度配置的物品种类和消除次数必须分离
|
||||
|
||||
- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;或者把第 11 到 20 个物品持久化为第 11 到 20 行,触发“系列素材图集持久化的行列索引必须落在 n*n 范围内”。
|
||||
- 现象:历史草稿选择标准 / 硬核难度后,系统可能把 `clearCount` 当成局内物品种类数量,导致标准需要 12 种、硬核需要 20/21 种;或者把第 11 到 20 个物品持久化为第 11 到 20 行,触发“系列素材图集持久化的行列索引必须落在 n\*n 范围内”。
|
||||
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
|
||||
- 处理:生成和持久化固定使用 20 个物品素材;运行态物品种类口径为轻松 3、标准 9、进阶 15、硬核 20,历史 `clearCount=20` 且难度为硬核的运行态仍可升为 21 组三消,但类型池不超过 20。10*10 sheet 每行两种物品、每种五个形态,持久化行列为 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1`。发布前按 `image_ready` 且有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
|
||||
- 处理:生成和持久化固定使用 20 个物品素材;运行态物品种类口径为轻松 3、标准 9、进阶 15、硬核 20,历史 `clearCount=20` 且难度为硬核的运行态仍可升为 21 组三消,但类型池不超过 20。10*10 sheet 每行两种物品、每种五个形态,持久化行列为 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1`。发布前按 `image_ready`且有`imageViews[]`或`imageSrc/imageObjectKey`的生成素材数量阻断不足难度;试玩不阻断,但通过`itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
|
||||
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`。
|
||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
|
||||
@@ -2554,6 +2562,7 @@
|
||||
- 处理:开局和补牌后的重排必须先排除现成消除,再用真实交换 / 落位模拟判断是否会产生新消除;`1x2` 永远不进入半锁定组,半锁定只允许 `1x3`、`2x2`、`2x3`。
|
||||
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 与 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml -- --nocapture` 通过后,开局盘面不应直接出现 completed group。
|
||||
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
|
||||
|
||||
## 推荐页作品 key 漏玩法会导致运行内容和标题作者错位
|
||||
|
||||
- 现象:移动端推荐页进入跳一跳或敲木鱼等作品时,游戏运行内容已经切到当前作品,但下方标题、作者和头像仍显示第一条拼图或其它推荐作品。
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# React 组件测试准则
|
||||
|
||||
更新时间:`2026-06-26`
|
||||
|
||||
## 背景
|
||||
|
||||
当前前端测试已经覆盖大量 React 组件、hook 和页面流程,但部分用例把组件内部状态、DOM 探针、按钮顺序、图标 class 或完整文案当成契约。这样的测试能快速发现改动,却也会让 UI 结构、交互文案和实现拆分变得很难迭代。
|
||||
|
||||
后续新增或重写 React 测试时,默认按本准则选择断言层级。已有测试不要求一次性批量迁移;当某个测试因正常重构频繁破碎,或本次任务正好修改该区域,就顺手收紧到稳定契约。
|
||||
|
||||
## 分层口径
|
||||
|
||||
1. 用户流程测试验证用户可感知结果。
|
||||
|
||||
优先使用 Testing Library 的 `userEvent`、role、label、可见弹窗、URL、提交结果、错误提示和外部 callback。测试名应描述业务行为,不描述组件内部状态机步骤。
|
||||
|
||||
2. 稳定契约测试验证对外边界。
|
||||
|
||||
对请求 DTO、callback payload、路由变化、持久化边界和后端回包映射,使用关键字段断言或 `expect.objectContaining(...)`。对象仍在演化时,不要断言完整对象、完整数组顺序或所有默认字段。
|
||||
|
||||
3. 模型和 hook 测试验证纯逻辑。
|
||||
|
||||
复杂状态机优先沉到 model 或 hook 的公开返回契约中测试。hook 测试使用 `renderHook` 直接调用公开方法和读取公开状态,不额外制造 `data-testid` 仪表盘组件来拼接内部字段。
|
||||
|
||||
## 避免的写法
|
||||
|
||||
- 不为了读取内部状态新建测试专用 DOM,例如 `<span data-testid="active-id">`、`textContent` 拼接内部数组或状态名。
|
||||
- 不把图标实现、第三方库 class、DOM 层级、完整按钮顺序当成稳定契约,除非产品明确要求该顺序或可访问语义本身就是行为。
|
||||
- 不用精确长文案锁死可变提示词、分享文案、错误文案或 UI 标签;需要断言时只断言稳定语义片段,或改断言 payload / 状态码 / 目标 callback。
|
||||
- 不在页面级测试里 mock 出一套与真实页面差异很大的“假壳”,再把假壳内部状态当作用户验收结果。页面壳测试可以保留,但应尽量断言 URL、标题、公开 callback 和真实可见行为。
|
||||
|
||||
## 推荐写法
|
||||
|
||||
- 交互优先用 `userEvent`,只有测试低层 pointer / wheel / drag 等浏览器事件细节时再用 `fireEvent`。
|
||||
- 查询优先用 role / label / alt / text 的用户语义;只有无可访问语义的画布参考线、不可见测量节点或渲染边界,才使用 `data-testid`。
|
||||
- `data-testid` 名称必须描述用户或稳定渲染边界,例如 `image-canvas-editor-snap-guide-vertical`;不要描述 React 私有 state 名称。
|
||||
- 测试用 fixture 只包含本行为需要的字段。演化中的 payload 使用 `expect.objectContaining(...)` 或 helper 生成默认对象,避免一处契约加字段导致大量无关用例碎裂。
|
||||
- 当测试是为防止历史回归,应在测试名或邻近注释中说明防的是什么行为,而不是记录实现步骤。
|
||||
|
||||
## 试点调整
|
||||
|
||||
`src/components/image-editor/useCanvasGenerationDialogs.test.tsx` 已从测试专用 DOM 仪表盘改为 `renderHook`,直接验证 hook 公开契约:打开、归档、激活、更新、删除、恢复和 ID 递增。
|
||||
|
||||
`src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx` 已去掉图标 class 和完整按钮顺序快照式断言,保留用户可操作按钮、工具切换 callback、可访问 pressed 状态和 hover / focus 打开选项的契约。
|
||||
|
||||
## 验证
|
||||
|
||||
修改 React 测试后,优先运行触达文件的定向测试,例如:
|
||||
|
||||
```bash
|
||||
npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx --reporter=dot
|
||||
```
|
||||
|
||||
涉及共享组件、路由壳或跨页面交互时,再追加对应页面测试、`npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。
|
||||
@@ -18,7 +18,7 @@ const baseUser: AuthUser = {
|
||||
wechatBound: true,
|
||||
};
|
||||
|
||||
test('绑定手机号表单复用平台输入和字段标题', async () => {
|
||||
test('绑定手机号表单展示当前身份并提交手机号验证码', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -42,23 +42,14 @@ test('绑定手机号表单复用平台输入和字段标题', async () => {
|
||||
const phoneInput = screen.getByLabelText('手机号') as HTMLInputElement;
|
||||
const codeInput = screen.getByLabelText('验证码') as HTMLInputElement;
|
||||
|
||||
expect(phoneInput.className).toContain('platform-text-field');
|
||||
expect(codeInput.className).toContain('platform-text-field');
|
||||
expect(screen.getByText('手机号').className).toContain(
|
||||
'text-[var(--platform-text-strong)]',
|
||||
);
|
||||
expect(screen.getByText('当前登录身份:微信旅人').className).toContain(
|
||||
'platform-subpanel',
|
||||
);
|
||||
expect(
|
||||
document
|
||||
.querySelector('.selection-hero-brand__image')
|
||||
?.getAttribute('src'),
|
||||
).toBe('/branding/taonier-product-ip.png');
|
||||
expect(screen.getByText('手机号')).toBeTruthy();
|
||||
expect(screen.getByText('当前登录身份:微信旅人')).toBeTruthy();
|
||||
|
||||
await user.type(phoneInput, '13800000000');
|
||||
await user.type(codeInput, '123456');
|
||||
await user.click(screen.getByRole('button', { name: '绑定手机号并进入游戏' }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '绑定手机号并进入游戏' }),
|
||||
);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('13800000000', '123456');
|
||||
});
|
||||
|
||||
@@ -46,17 +46,9 @@ test('shows cost range and opens an independent adjustment dialog', () => {
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: /调整/u }));
|
||||
const adjustDialog = screen.getByRole('dialog', { name: '调整拼图模板' });
|
||||
expect(adjustDialog.parentElement).not.toBe(confirmDialog);
|
||||
expect(within(adjustDialog).getByText('关卡数').className).toContain(
|
||||
'inline-flex',
|
||||
);
|
||||
expect(within(adjustDialog).getByText('关卡数')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(adjustDialog).getByRole('button', { name: '多关卡' }));
|
||||
const levelCountInput = within(adjustDialog).getByLabelText('计划关卡数');
|
||||
expect(levelCountInput.className).toContain('bg-white/90');
|
||||
expect(levelCountInput.className).toContain(
|
||||
'focus:ring-[var(--platform-warm-border)]',
|
||||
);
|
||||
expect(levelCountInput.className).toContain('font-bold');
|
||||
fireEvent.change(within(adjustDialog).getByLabelText('计划关卡数'), {
|
||||
target: { value: '4' },
|
||||
});
|
||||
@@ -86,14 +78,7 @@ test('template preview uses platform media frame with image and fallback states'
|
||||
);
|
||||
|
||||
const previewImage = screen.getByRole('img', { name: '创意拼图' });
|
||||
const previewFrame = previewImage.closest('div.relative');
|
||||
|
||||
expect(previewFrame?.className).toContain('aspect-[16/9]');
|
||||
expect(previewFrame?.className).toContain(
|
||||
'border-[var(--platform-subpanel-border)]',
|
||||
);
|
||||
expect(previewFrame?.className).toContain('bg-white/68');
|
||||
expect(previewFrame?.className).toContain('rounded-[1.25rem]');
|
||||
expect(previewImage.getAttribute('src')).toBe('/template-preview.webp');
|
||||
|
||||
const selectionWithoutPreview = {
|
||||
...createSelection(),
|
||||
@@ -108,16 +93,6 @@ test('template preview uses platform media frame with image and fallback states'
|
||||
/>,
|
||||
);
|
||||
|
||||
const fallbackFrame = screen
|
||||
.getByRole('dialog', {
|
||||
name: '确认拼图模板',
|
||||
})
|
||||
.querySelector('div.relative.aspect-\\[16\\/9\\]');
|
||||
const fallbackIcon = fallbackFrame?.querySelector('svg');
|
||||
|
||||
expect(fallbackFrame?.className).toContain('aspect-[16/9]');
|
||||
expect(fallbackFrame?.className).toContain(
|
||||
'border-[var(--platform-subpanel-border)]',
|
||||
);
|
||||
expect(fallbackIcon?.closest('span')?.className).toContain('bg-white/84');
|
||||
expect(screen.queryByRole('img', { name: '创意拼图' })).toBeNull();
|
||||
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { createRef, type RefObject } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
|
||||
|
||||
describe('ImageCanvasBottomToolbarView', () => {
|
||||
it('renders the canvas tools and forwards tool changes', () => {
|
||||
function renderToolbar(
|
||||
overrides: Partial<Parameters<typeof ImageCanvasBottomToolbarView>[0]> = {},
|
||||
) {
|
||||
const switchTool = vi.fn();
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
const view = render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
@@ -21,71 +24,88 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
onSwitchTool={switchTool}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
onCloseToolOptions={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
return {
|
||||
...view,
|
||||
toolbar: screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
|
||||
switchTool,
|
||||
refs: {
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '生成图片' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '选择工具' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '抓手工具' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '生成视频' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '生成规范' }));
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '生成UI设计图' }),
|
||||
);
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '生成音乐' }));
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '生成图标素材' }),
|
||||
);
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '宣发素材' }));
|
||||
|
||||
expect(switchTool).toHaveBeenNthCalledWith(1, 'hand');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(2, 'video');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(3, 'spec');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(4, 'ui-design');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(5, 'music');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(6, 'icon');
|
||||
expect(switchTool).toHaveBeenNthCalledWith(7, 'publication');
|
||||
});
|
||||
|
||||
it('only keeps the select and hand tools visibly pressed', () => {
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const renderToolbar = (effectiveTool: Parameters<
|
||||
function renderToolbarView(
|
||||
effectiveTool: Parameters<
|
||||
typeof ImageCanvasBottomToolbarView
|
||||
>[0]['effectiveTool']) => (
|
||||
>[0]['effectiveTool'],
|
||||
refs: {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
specToolWrapRef={refs.specToolWrapRef}
|
||||
musicToolWrapRef={refs.musicToolWrapRef}
|
||||
publicationToolWrapRef={refs.publicationToolWrapRef}
|
||||
effectiveTool={effectiveTool}
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
onCloseToolOptions={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { rerender } = render(renderToolbar('select'));
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
it('renders the expected canvas tools and forwards user selections', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { toolbar, switchTool } = renderToolbar();
|
||||
|
||||
const toolExpectations = [
|
||||
['选择工具', 'select'],
|
||||
['抓手工具', 'hand'],
|
||||
['上传工具', 'upload'],
|
||||
['生成图片', 'generate'],
|
||||
['生成视频', 'video'],
|
||||
['生成音乐', 'music'],
|
||||
['生成规范', 'spec'],
|
||||
['生成角色形象', 'character'],
|
||||
['生成图标素材', 'icon'],
|
||||
['生成UI设计图', 'ui-design'],
|
||||
['宣发素材', 'publication'],
|
||||
] as const;
|
||||
|
||||
for (const [label, tool] of toolExpectations) {
|
||||
await user.click(within(toolbar).getByRole('button', { name: label }));
|
||||
expect(switchTool).toHaveBeenLastCalledWith(tool);
|
||||
}
|
||||
|
||||
expect(switchTool).toHaveBeenCalledTimes(toolExpectations.length);
|
||||
});
|
||||
|
||||
it('only exposes persistent pressed state for navigation tools', () => {
|
||||
const { toolbar, rerender, refs } = renderToolbar({
|
||||
effectiveTool: 'select',
|
||||
});
|
||||
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '选择工具' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '生成图片' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
|
||||
rerender(renderToolbar('hand'));
|
||||
rerender(renderToolbarView('hand', refs));
|
||||
|
||||
expect(
|
||||
within(toolbar)
|
||||
@@ -93,119 +113,44 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
for (const tool of [
|
||||
['upload', '上传工具'],
|
||||
['generate', '生成图片'],
|
||||
['video', '生成视频'],
|
||||
['music', '生成音乐'],
|
||||
['spec', '生成规范'],
|
||||
['character', '生成角色形象'],
|
||||
['icon', '生成图标素材'],
|
||||
['ui-design', '生成UI设计图'],
|
||||
['publication', '宣发素材'],
|
||||
] as const) {
|
||||
rerender(renderToolbar(tool[0]));
|
||||
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: tool[1] })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
}
|
||||
});
|
||||
|
||||
it('把宣发素材入口放在生成UI设计图右侧', () => {
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="select"
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
onCloseToolOptions={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toolNames = within(
|
||||
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
|
||||
)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label'));
|
||||
|
||||
expect(toolNames).toEqual([
|
||||
'选择工具',
|
||||
'抓手工具',
|
||||
'上传工具',
|
||||
'生成图片',
|
||||
'生成视频',
|
||||
'生成音乐',
|
||||
'生成规范',
|
||||
'生成角色形象',
|
||||
'生成图标素材',
|
||||
'生成UI设计图',
|
||||
'宣发素材',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses an upload-specific icon for the upload tool', () => {
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="select"
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
onCloseToolOptions={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
rerender(renderToolbarView('music', refs));
|
||||
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
|
||||
)
|
||||
.getByRole('button', { name: '上传工具' })
|
||||
.querySelector('.lucide-upload'),
|
||||
).toBeTruthy();
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '生成音乐' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('opens and closes bottom option tools on hover', () => {
|
||||
it('opens and closes bottom option tools on pointer and keyboard focus', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openToolOptions = vi.fn();
|
||||
const closeToolOptions = vi.fn();
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="select"
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={openToolOptions}
|
||||
onCloseToolOptions={closeToolOptions}
|
||||
/>,
|
||||
const { toolbar, refs } = renderToolbar({
|
||||
onOpenToolOptions: openToolOptions,
|
||||
onCloseToolOptions: closeToolOptions,
|
||||
});
|
||||
|
||||
await user.hover(within(toolbar).getByRole('button', { name: '生成规范' }));
|
||||
await user.unhover(
|
||||
within(toolbar).getByRole('button', { name: '生成规范' }),
|
||||
);
|
||||
await user.hover(within(toolbar).getByRole('button', { name: '生成音乐' }));
|
||||
await user.unhover(
|
||||
within(toolbar).getByRole('button', { name: '生成音乐' }),
|
||||
);
|
||||
|
||||
fireEvent.pointerEnter(specToolWrapRef.current!);
|
||||
fireEvent.pointerLeave(specToolWrapRef.current!);
|
||||
fireEvent.pointerEnter(musicToolWrapRef.current!);
|
||||
fireEvent.pointerLeave(musicToolWrapRef.current!);
|
||||
fireEvent.pointerEnter(publicationToolWrapRef.current!);
|
||||
fireEvent.pointerLeave(publicationToolWrapRef.current!);
|
||||
const publicationTool = within(toolbar).getByRole('button', {
|
||||
name: '宣发素材',
|
||||
});
|
||||
publicationTool.focus();
|
||||
publicationTool.blur();
|
||||
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(1, 'spec');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(1, 'spec');
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(2, 'music');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(2, 'music');
|
||||
expect(openToolOptions).toHaveBeenNthCalledWith(3, 'publication');
|
||||
expect(closeToolOptions).toHaveBeenNthCalledWith(3, 'publication');
|
||||
expect(openToolOptions).toHaveBeenCalledWith('spec');
|
||||
expect(closeToolOptions).toHaveBeenCalledWith('spec');
|
||||
expect(openToolOptions).toHaveBeenCalledWith('music');
|
||||
expect(closeToolOptions).toHaveBeenCalledWith('music');
|
||||
expect(openToolOptions).toHaveBeenCalledWith('publication');
|
||||
expect(closeToolOptions).toHaveBeenCalledWith('publication');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
|
||||
@@ -26,235 +26,204 @@ function createDialog(
|
||||
};
|
||||
}
|
||||
|
||||
function GenerationDialogsHarness({ onActivate }: { onActivate: () => void }) {
|
||||
const dialogs = useCanvasGenerationDialogs({ onActivate });
|
||||
const activeId = dialogs.activeCanvasGenerationDialog?.id ?? '-';
|
||||
const activePrompt = dialogs.activeCanvasGenerationDialog?.prompt ?? '-';
|
||||
const inactiveIds = dialogs.inactiveGenerateDialogs
|
||||
.map((dialog) => `${dialog.id}:${dialog.prompt}:${dialog.composerOpen}`)
|
||||
.join('|');
|
||||
const allPrompts = dialogs.canvasGenerationDialogs
|
||||
.map((dialog) => dialog.prompt)
|
||||
.join(',');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="active-id">{activeId}</span>
|
||||
<span data-testid="active-prompt">{activePrompt}</span>
|
||||
<span data-testid="inactive">{inactiveIds}</span>
|
||||
<span data-testid="all-prompts">{allPrompts}</span>
|
||||
<span data-testid="placeholder-x">
|
||||
{String(
|
||||
dialogs.getGeneratingDialogPlaceholder(
|
||||
dialogs.activeCanvasGenerationDialog ?? {
|
||||
mode: 'generate',
|
||||
prompt: 'fallback',
|
||||
status: 'idle',
|
||||
},
|
||||
)?.x ?? '-',
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dialogs.openCanvasGenerationDialog(createDialog('generate', 'first'))}
|
||||
>
|
||||
open first
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dialogs.openCanvasGenerationDialog(createDialog('character', 'second'))}
|
||||
>
|
||||
open second
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = dialogs.inactiveGenerateDialogs[0];
|
||||
if (target) {
|
||||
dialogs.activateCanvasGenerationDialog(target);
|
||||
}
|
||||
}}
|
||||
>
|
||||
activate inactive
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = dialogs.activeCanvasGenerationDialog;
|
||||
if (target) {
|
||||
dialogs.updateCanvasGenerationDialogById(target.id, (dialog) => ({
|
||||
...dialog,
|
||||
status: 'generating',
|
||||
placeholder: dialog.placeholder
|
||||
? {
|
||||
...dialog.placeholder,
|
||||
x: 99,
|
||||
}
|
||||
: dialog.placeholder,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
update active
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = dialogs.activeCanvasGenerationDialog;
|
||||
if (target) {
|
||||
dialogs.removeCanvasGenerationDialogById(target.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
remove active
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = dialogs.activeCanvasGenerationDialog;
|
||||
if (target) {
|
||||
dialogs.updateCanvasGenerationDialogById(target.id, (dialog) => ({
|
||||
...dialog,
|
||||
sourceLayerId: 'layer-a',
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
bind layer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dialogs.removeCanvasGenerationDialogsByLayerId('layer-a')}
|
||||
>
|
||||
remove layer dialogs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dialogs.restoreCanvasGenerationDialogs([
|
||||
{
|
||||
id: 'generation-dialog-12',
|
||||
mode: 'generate',
|
||||
prompt: 'restored',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
placeholder: {
|
||||
x: 12,
|
||||
y: 24,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
restore saved
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dialogs.restoreCanvasGenerationDialogs([
|
||||
{
|
||||
id: 'generation-dialog-2',
|
||||
mode: 'generate',
|
||||
prompt: 'inactive saved',
|
||||
status: 'idle',
|
||||
composerOpen: false,
|
||||
},
|
||||
{
|
||||
id: 'generation-dialog-3',
|
||||
mode: 'character',
|
||||
prompt: 'active saved',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
restore active
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useCanvasGenerationDialogs', () => {
|
||||
it('archives, activates, updates, and removes canvas generation dialogs', () => {
|
||||
const onActivate = vi.fn();
|
||||
render(<GenerationDialogsHarness onActivate={onActivate} />);
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'open first' }).click());
|
||||
expect(screen.getByTestId('active-prompt').textContent).toBe('first');
|
||||
expect(screen.getByTestId('all-prompts').textContent).toBe('first');
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'open second' }).click());
|
||||
expect(screen.getByTestId('active-prompt').textContent).toBe('second');
|
||||
expect(screen.getByTestId('inactive').textContent).toContain(
|
||||
'generation-dialog-1:first:false',
|
||||
);
|
||||
expect(screen.getByTestId('all-prompts').textContent).toBe(
|
||||
'first,second',
|
||||
const { result } = renderHook(() =>
|
||||
useCanvasGenerationDialogs({ onActivate }),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
screen.getByRole('button', { name: 'activate inactive' }).click(),
|
||||
act(() => {
|
||||
result.current.openCanvasGenerationDialog(
|
||||
createDialog('generate', 'first'),
|
||||
);
|
||||
});
|
||||
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
||||
expect.objectContaining({
|
||||
prompt: 'first',
|
||||
composerOpen: true,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openCanvasGenerationDialog(
|
||||
createDialog('character', 'second'),
|
||||
);
|
||||
});
|
||||
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
||||
expect.objectContaining({
|
||||
prompt: 'second',
|
||||
composerOpen: true,
|
||||
}),
|
||||
);
|
||||
expect(result.current.inactiveGenerateDialogs).toEqual([
|
||||
expect.objectContaining({
|
||||
prompt: 'first',
|
||||
composerOpen: false,
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
result.current.canvasGenerationDialogs.map((dialog) => dialog.prompt),
|
||||
).toEqual(['first', 'second']);
|
||||
|
||||
const archivedFirstDialog = result.current.inactiveGenerateDialogs[0];
|
||||
expect(archivedFirstDialog).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
result.current.activateCanvasGenerationDialog(archivedFirstDialog!);
|
||||
});
|
||||
expect(onActivate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('active-prompt').textContent).toBe('first');
|
||||
expect(screen.getByTestId('inactive').textContent).toContain(
|
||||
'generation-dialog-2:second:false',
|
||||
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
||||
expect.objectContaining({
|
||||
prompt: 'first',
|
||||
composerOpen: true,
|
||||
}),
|
||||
);
|
||||
expect(result.current.inactiveGenerateDialogs).toEqual([
|
||||
expect.objectContaining({
|
||||
prompt: 'second',
|
||||
composerOpen: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'update active' }).click());
|
||||
expect(screen.getByTestId('placeholder-x').textContent).toBe('99');
|
||||
const activeDialog = result.current.activeCanvasGenerationDialog;
|
||||
expect(activeDialog).toBeTruthy();
|
||||
act(() => {
|
||||
result.current.updateCanvasGenerationDialogById(
|
||||
activeDialog!.id,
|
||||
(dialog) => ({
|
||||
...dialog,
|
||||
status: 'generating',
|
||||
placeholder: dialog.placeholder
|
||||
? {
|
||||
...dialog.placeholder,
|
||||
x: 99,
|
||||
}
|
||||
: dialog.placeholder,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
result.current.getGeneratingDialogPlaceholder(activeDialog!)?.x,
|
||||
).toBe(99);
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'bind layer' }).click());
|
||||
act(() =>
|
||||
screen.getByRole('button', { name: 'remove layer dialogs' }).click(),
|
||||
);
|
||||
expect(screen.getByTestId('active-prompt').textContent).toBe('-');
|
||||
expect(screen.getByTestId('inactive').textContent).toContain('second');
|
||||
act(() => {
|
||||
result.current.updateCanvasGenerationDialogById(
|
||||
activeDialog!.id,
|
||||
(dialog) => ({
|
||||
...dialog,
|
||||
sourceLayerId: 'layer-a',
|
||||
}),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
result.current.removeCanvasGenerationDialogsByLayerId('layer-a');
|
||||
});
|
||||
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
||||
expect(result.current.inactiveGenerateDialogs).toEqual([
|
||||
expect.objectContaining({
|
||||
prompt: 'second',
|
||||
}),
|
||||
]);
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'activate inactive' }).click());
|
||||
act(() => screen.getByRole('button', { name: 'remove active' }).click());
|
||||
expect(screen.getByTestId('active-prompt').textContent).toBe('-');
|
||||
expect(screen.getByTestId('inactive').textContent).toBe('');
|
||||
const archivedSecondDialog = result.current.inactiveGenerateDialogs[0];
|
||||
expect(archivedSecondDialog).toBeDefined();
|
||||
act(() => {
|
||||
result.current.activateCanvasGenerationDialog(archivedSecondDialog!);
|
||||
});
|
||||
act(() => {
|
||||
result.current.removeCanvasGenerationDialogById(archivedSecondDialog!.id);
|
||||
});
|
||||
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
||||
expect(result.current.inactiveGenerateDialogs).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores saved dialogs and continues ids after the saved maximum', () => {
|
||||
const onActivate = vi.fn();
|
||||
render(<GenerationDialogsHarness onActivate={onActivate} />);
|
||||
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'restore saved' }).click());
|
||||
expect(screen.getByTestId('active-id').textContent).toBe(
|
||||
'generation-dialog-12',
|
||||
act(() => {
|
||||
result.current.restoreCanvasGenerationDialogs([
|
||||
{
|
||||
id: 'generation-dialog-12',
|
||||
mode: 'generate',
|
||||
prompt: 'restored',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
placeholder: {
|
||||
x: 12,
|
||||
y: 24,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'generation-dialog-12',
|
||||
prompt: 'restored',
|
||||
}),
|
||||
);
|
||||
expect(screen.getByTestId('active-prompt').textContent).toBe('restored');
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'open second' }).click());
|
||||
expect(screen.getByTestId('active-id').textContent).toBe(
|
||||
'generation-dialog-13',
|
||||
);
|
||||
expect(screen.getByTestId('inactive').textContent).toContain(
|
||||
'generation-dialog-12:restored:false',
|
||||
let nextDialogId = '';
|
||||
act(() => {
|
||||
nextDialogId = result.current.openCanvasGenerationDialog(
|
||||
createDialog('character', 'second'),
|
||||
);
|
||||
});
|
||||
expect(nextDialogId).toBe('generation-dialog-13');
|
||||
expect(result.current.inactiveGenerateDialogs).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'generation-dialog-12',
|
||||
prompt: 'restored',
|
||||
composerOpen: false,
|
||||
}),
|
||||
]);
|
||||
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'generation-dialog-13',
|
||||
prompt: 'second',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the saved open dialog active when restoring multiple dialogs', () => {
|
||||
const onActivate = vi.fn();
|
||||
render(<GenerationDialogsHarness onActivate={onActivate} />);
|
||||
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
||||
|
||||
act(() => screen.getByRole('button', { name: 'restore active' }).click());
|
||||
act(() => {
|
||||
result.current.restoreCanvasGenerationDialogs([
|
||||
{
|
||||
id: 'generation-dialog-2',
|
||||
mode: 'generate',
|
||||
prompt: 'inactive saved',
|
||||
status: 'idle',
|
||||
composerOpen: false,
|
||||
},
|
||||
{
|
||||
id: 'generation-dialog-3',
|
||||
mode: 'character',
|
||||
prompt: 'active saved',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('active-id').textContent).toBe(
|
||||
'generation-dialog-3',
|
||||
);
|
||||
expect(screen.getByTestId('active-prompt').textContent).toBe(
|
||||
'active saved',
|
||||
);
|
||||
expect(screen.getByTestId('inactive').textContent).toContain(
|
||||
'generation-dialog-2:inactive saved:false',
|
||||
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'generation-dialog-3',
|
||||
prompt: 'active saved',
|
||||
composerOpen: true,
|
||||
}),
|
||||
);
|
||||
expect(result.current.inactiveGenerateDialogs).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'generation-dialog-2',
|
||||
prompt: 'inactive saved',
|
||||
composerOpen: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -18,107 +18,12 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function ChromeHarness({
|
||||
function renderChrome({
|
||||
openEditorLoginModal = vi.fn(),
|
||||
}: {
|
||||
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
|
||||
}) {
|
||||
const chrome = useImageCanvasEditorChrome({ openEditorLoginModal });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="title">{chrome.projectTitle}</span>
|
||||
<span data-testid="rename-value">{chrome.projectRenameValue}</span>
|
||||
<span data-testid="renaming">{String(chrome.isRenamingProject)}</span>
|
||||
<span data-testid="saving">{String(chrome.isProjectRenameSaving)}</span>
|
||||
<span data-testid="rename-error">{chrome.projectRenameError ?? '-'}</span>
|
||||
<span data-testid="sidebar">{chrome.activeSidebarPanel ?? '-'}</span>
|
||||
<span data-testid="tool">{chrome.activeTool}</span>
|
||||
<span data-testid="zoom">{String(chrome.isZoomMenuOpen)}</span>
|
||||
<span data-testid="background-open">
|
||||
{String(chrome.isBackgroundSettingsOpen)}
|
||||
</span>
|
||||
<span data-testid="minimap">{String(chrome.isMinimapOpen)}</span>
|
||||
<span data-testid="background-color">
|
||||
{chrome.canvasBackgroundColor}
|
||||
</span>
|
||||
<span data-testid="background-hex">
|
||||
{chrome.canvasBackgroundHexValue}
|
||||
</span>
|
||||
<button type="button" onClick={chrome.startProjectRename}>
|
||||
start rename
|
||||
</button>
|
||||
<button type="button" onClick={chrome.cancelProjectRename}>
|
||||
cancel rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chrome.submitProjectRename('project-1')}
|
||||
>
|
||||
submit rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chrome.submitProjectRename(null)}
|
||||
>
|
||||
submit without project
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chrome.setProjectRenameValue(' 新项目 ')}
|
||||
>
|
||||
set rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chrome.setProjectRenameValue(' ')}
|
||||
>
|
||||
blank rename
|
||||
</button>
|
||||
<button type="button" onClick={() => chrome.setProjectTitle('已有项目')}>
|
||||
set title
|
||||
</button>
|
||||
<button type="button" onClick={() => chrome.setActiveTool('hand')}>
|
||||
set hand
|
||||
</button>
|
||||
<button type="button" onClick={() => chrome.toggleSidebarPanel('assets')}>
|
||||
toggle assets
|
||||
</button>
|
||||
<button type="button" onClick={() => chrome.toggleSidebarPanel('layers')}>
|
||||
toggle layers
|
||||
</button>
|
||||
<button type="button" onClick={chrome.toggleZoomMenu}>
|
||||
toggle zoom
|
||||
</button>
|
||||
<button type="button" onClick={chrome.toggleBackgroundSettings}>
|
||||
toggle background
|
||||
</button>
|
||||
<button type="button" onClick={chrome.toggleMinimap}>
|
||||
toggle minimap
|
||||
</button>
|
||||
<button type="button" onClick={chrome.closeEditorChromePanels}>
|
||||
close panels
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chrome.applyCanvasBackgroundColor('#abc')}
|
||||
>
|
||||
apply short hex
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chrome.handleCanvasBackgroundHexChange('#not-a-color')}
|
||||
>
|
||||
invalid hex
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chrome.handleCanvasBackgroundHexChange('#ffffff')}
|
||||
>
|
||||
valid hex
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} = {}) {
|
||||
return renderHook(() => useImageCanvasEditorChrome({ openEditorLoginModal }));
|
||||
}
|
||||
|
||||
describe('useImageCanvasEditorChrome', () => {
|
||||
@@ -135,15 +40,23 @@ describe('useImageCanvasEditorChrome', () => {
|
||||
resources: [],
|
||||
updatedAt: '2026-06-17T00:00:00.000Z',
|
||||
});
|
||||
render(<ChromeHarness />);
|
||||
const { result } = renderChrome();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'set title' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'start rename' }));
|
||||
expect(screen.getByTestId('rename-value').textContent).toBe('已有项目');
|
||||
act(() => {
|
||||
result.current.setProjectTitle('已有项目');
|
||||
});
|
||||
act(() => {
|
||||
result.current.startProjectRename();
|
||||
});
|
||||
expect(result.current.projectRenameValue).toBe('已有项目');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'set rename' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit rename' }));
|
||||
expect(screen.getByTestId('saving').textContent).toBe('true');
|
||||
act(() => {
|
||||
result.current.setProjectRenameValue(' 新项目 ');
|
||||
});
|
||||
act(() => {
|
||||
result.current.submitProjectRename('project-1');
|
||||
});
|
||||
expect(result.current.isProjectRenameSaving).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renameEditorProjectMock).toHaveBeenCalledWith(
|
||||
@@ -151,10 +64,12 @@ describe('useImageCanvasEditorChrome', () => {
|
||||
'新项目',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('title').textContent).toBe('后端项目名');
|
||||
expect(screen.getByTestId('rename-value').textContent).toBe('后端项目名');
|
||||
expect(screen.getByTestId('renaming').textContent).toBe('false');
|
||||
expect(screen.getByTestId('saving').textContent).toBe('false');
|
||||
await waitFor(() => {
|
||||
expect(result.current.isProjectRenameSaving).toBe(false);
|
||||
});
|
||||
expect(result.current.projectTitle).toBe('后端项目名');
|
||||
expect(result.current.projectRenameValue).toBe('后端项目名');
|
||||
expect(result.current.isRenamingProject).toBe(false);
|
||||
});
|
||||
|
||||
it('validates rename input and opens login on rename auth errors', async () => {
|
||||
@@ -166,59 +81,70 @@ describe('useImageCanvasEditorChrome', () => {
|
||||
code: 'UNAUTHORIZED',
|
||||
}),
|
||||
);
|
||||
render(<ChromeHarness openEditorLoginModal={openEditorLoginModal} />);
|
||||
const { result } = renderChrome({ openEditorLoginModal });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'blank rename' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit rename' }));
|
||||
expect(screen.getByTestId('rename-error').textContent).toBe(
|
||||
'项目名称不能为空',
|
||||
);
|
||||
act(() => {
|
||||
result.current.setProjectRenameValue(' ');
|
||||
});
|
||||
act(() => {
|
||||
result.current.submitProjectRename('project-1');
|
||||
});
|
||||
expect(result.current.projectRenameError).toBe('项目名称不能为空');
|
||||
expect(renameEditorProjectMock).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'set rename' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit rename' }));
|
||||
act(() => {
|
||||
result.current.setProjectRenameValue('新项目');
|
||||
});
|
||||
act(() => {
|
||||
result.current.submitProjectRename('project-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.getByTestId('rename-error').textContent).toBe('未授权访问');
|
||||
expect(result.current.projectRenameError).toBe('未授权访问');
|
||||
});
|
||||
|
||||
it('manages background colors and chrome panel toggles', () => {
|
||||
render(<ChromeHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'apply short hex' }));
|
||||
expect(screen.getByTestId('background-color').textContent).toBe('#aabbcc');
|
||||
expect(screen.getByTestId('background-hex').textContent).toBe('#aabbcc');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'invalid hex' }));
|
||||
expect(screen.getByTestId('background-color').textContent).toBe('#aabbcc');
|
||||
expect(screen.getByTestId('background-hex').textContent).toBe(
|
||||
'#not-a-color',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'valid hex' }));
|
||||
expect(screen.getByTestId('background-color').textContent).toBe('#ffffff');
|
||||
expect(screen.getByTestId('background-hex').textContent).toBe('#ffffff');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle assets' }));
|
||||
expect(screen.getByTestId('sidebar').textContent).toBe('-');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle layers' }));
|
||||
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle zoom' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle background' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'toggle minimap' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'set hand' }));
|
||||
expect(screen.getByTestId('zoom').textContent).toBe('true');
|
||||
expect(screen.getByTestId('background-open').textContent).toBe('true');
|
||||
expect(screen.getByTestId('minimap').textContent).toBe('false');
|
||||
expect(screen.getByTestId('tool').textContent).toBe('hand');
|
||||
const { result } = renderChrome();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'close panels' }).click();
|
||||
result.current.applyCanvasBackgroundColor('#abc');
|
||||
});
|
||||
expect(screen.getByTestId('sidebar').textContent).toBe('-');
|
||||
expect(screen.getByTestId('zoom').textContent).toBe('false');
|
||||
expect(screen.getByTestId('background-open').textContent).toBe('false');
|
||||
expect(result.current.canvasBackgroundColor).toBe('#aabbcc');
|
||||
expect(result.current.canvasBackgroundHexValue).toBe('#aabbcc');
|
||||
|
||||
act(() => {
|
||||
result.current.handleCanvasBackgroundHexChange('#not-a-color');
|
||||
});
|
||||
expect(result.current.canvasBackgroundColor).toBe('#aabbcc');
|
||||
expect(result.current.canvasBackgroundHexValue).toBe('#not-a-color');
|
||||
|
||||
act(() => {
|
||||
result.current.handleCanvasBackgroundHexChange('#ffffff');
|
||||
});
|
||||
expect(result.current.canvasBackgroundColor).toBe('#ffffff');
|
||||
expect(result.current.canvasBackgroundHexValue).toBe('#ffffff');
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSidebarPanel('assets');
|
||||
result.current.toggleSidebarPanel('layers');
|
||||
result.current.toggleZoomMenu();
|
||||
result.current.toggleBackgroundSettings();
|
||||
result.current.toggleMinimap();
|
||||
result.current.setActiveTool('hand');
|
||||
});
|
||||
expect(result.current.activeSidebarPanel).toBe('layers');
|
||||
expect(result.current.isZoomMenuOpen).toBe(true);
|
||||
expect(result.current.isBackgroundSettingsOpen).toBe(true);
|
||||
expect(result.current.isMinimapOpen).toBe(false);
|
||||
expect(result.current.activeTool).toBe('hand');
|
||||
|
||||
act(() => {
|
||||
result.current.closeEditorChromePanels();
|
||||
});
|
||||
expect(result.current.activeSidebarPanel).toBeNull();
|
||||
expect(result.current.isZoomMenuOpen).toBe(false);
|
||||
expect(result.current.isBackgroundSettingsOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
import { act, fireEvent, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasViewportControls } from './useImageCanvasViewportControls';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer>): CanvasLayer {
|
||||
@@ -52,159 +51,110 @@ function setElementBox(
|
||||
}) as DOMRect;
|
||||
}
|
||||
|
||||
function ViewportHarness({
|
||||
captureCanvasHistory = vi.fn(),
|
||||
}: {
|
||||
captureCanvasHistory?: () => void;
|
||||
}) {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const layers = [
|
||||
function createViewportLayers() {
|
||||
return [
|
||||
createLayer({ id: 'one', x: 0, y: 0, width: 400, height: 300 }),
|
||||
createLayer({ id: 'two', x: 600, y: 100, width: 200, height: 200 }),
|
||||
];
|
||||
const controls = useImageCanvasViewportControls({
|
||||
canvasViewportRef: viewportRef,
|
||||
layers,
|
||||
captureCanvasHistory,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={(element) => {
|
||||
viewportRef.current = element;
|
||||
if (element) {
|
||||
setElementBox(element, { width: 900, height: 640 });
|
||||
}
|
||||
}}
|
||||
data-testid="viewport-element"
|
||||
/>
|
||||
<div
|
||||
className="image-canvas-editor__minimap"
|
||||
data-testid="minimap"
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
setElementBox(element, {
|
||||
left: 20,
|
||||
top: 30,
|
||||
width: 160,
|
||||
height: 120,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span data-testid="viewport">
|
||||
{controls.viewport.x.toFixed(2)},{controls.viewport.y.toFixed(2)},
|
||||
{controls.viewport.scale.toFixed(2)}
|
||||
</span>
|
||||
<span data-testid="canvas-size">
|
||||
{controls.canvasSize.width}x{controls.canvasSize.height}
|
||||
</span>
|
||||
<span data-testid="drop-point">
|
||||
{JSON.stringify(controls.getCanvasDropPoint(260, 190))}
|
||||
</span>
|
||||
<span data-testid="world-point">
|
||||
{JSON.stringify(controls.getCanvasPointFromClient(260, 190))}
|
||||
</span>
|
||||
<span data-testid="minimap-count">
|
||||
{controls.minimapModel?.layers.length ?? 0}
|
||||
</span>
|
||||
<button type="button" onClick={() => controls.fitLayers()}>
|
||||
fit
|
||||
</button>
|
||||
<button type="button" onClick={() => controls.updateScaleFromCenter(2)}>
|
||||
zoom center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
controls.updateViewportFromMinimapDrag(
|
||||
{
|
||||
kind: 'minimap',
|
||||
pointerId: 1,
|
||||
startClientX: 100,
|
||||
startClientY: 100,
|
||||
startViewport: { x: -100, y: -50, scale: 1 },
|
||||
minimapScale: controls.minimapModel?.scale ?? 1,
|
||||
moved: true,
|
||||
},
|
||||
104,
|
||||
103,
|
||||
);
|
||||
}}
|
||||
>
|
||||
minimap drag
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const dragState = {
|
||||
kind: 'minimap' as const,
|
||||
pointerId: 2,
|
||||
startClientX: 100,
|
||||
startClientY: 100,
|
||||
startViewport: { x: -100, y: -50, scale: 1 },
|
||||
minimapScale: controls.minimapModel?.scale ?? 1,
|
||||
moved: true,
|
||||
};
|
||||
controls.updateViewportFromMinimapDrag(dragState, 104, 103);
|
||||
controls.updateViewportFromMinimapDrag(dragState, 112, 109);
|
||||
}}
|
||||
>
|
||||
minimap drag twice
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => controls.flushMinimapViewportDrag()}
|
||||
>
|
||||
flush minimap drag
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => controls.moveViewportFromMinimapPointer(100, 90)}
|
||||
>
|
||||
minimap click
|
||||
</button>
|
||||
</div>
|
||||
function renderViewportControls({
|
||||
captureCanvasHistory = vi.fn(),
|
||||
layers = createViewportLayers(),
|
||||
}: {
|
||||
captureCanvasHistory?: () => void;
|
||||
layers?: CanvasLayer[];
|
||||
} = {}) {
|
||||
const viewportElement = document.createElement('div');
|
||||
setElementBox(viewportElement, { width: 900, height: 640 });
|
||||
document.body.append(viewportElement);
|
||||
|
||||
const minimapElement = document.createElement('div');
|
||||
minimapElement.className = 'image-canvas-editor__minimap';
|
||||
setElementBox(minimapElement, {
|
||||
left: 20,
|
||||
top: 30,
|
||||
width: 160,
|
||||
height: 120,
|
||||
});
|
||||
document.body.append(minimapElement);
|
||||
|
||||
const canvasViewportRef = { current: viewportElement };
|
||||
const hook = renderHook(() =>
|
||||
useImageCanvasViewportControls({
|
||||
canvasViewportRef,
|
||||
layers,
|
||||
captureCanvasHistory,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...hook,
|
||||
viewportElement,
|
||||
captureCanvasHistory,
|
||||
};
|
||||
}
|
||||
|
||||
function createMinimapDragState(minimapScale: number) {
|
||||
return {
|
||||
kind: 'minimap' as const,
|
||||
pointerId: 1,
|
||||
startClientX: 100,
|
||||
startClientY: 100,
|
||||
startViewport: { x: -100, y: -50, scale: 1 },
|
||||
minimapScale,
|
||||
moved: true,
|
||||
};
|
||||
}
|
||||
|
||||
function expectViewport(viewport: CanvasViewport, expected: CanvasViewport) {
|
||||
expect(viewport.x).toBeCloseTo(expected.x);
|
||||
expect(viewport.y).toBeCloseTo(expected.y);
|
||||
expect(viewport.scale).toBeCloseTo(expected.scale);
|
||||
}
|
||||
|
||||
describe('useImageCanvasViewportControls', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('owns canvas size, fit view, center zoom and canvas point helpers', () => {
|
||||
const captureCanvasHistory = vi.fn();
|
||||
render(
|
||||
<ViewportHarness captureCanvasHistory={captureCanvasHistory} />,
|
||||
);
|
||||
it('owns canvas size, minimap model and canvas point helpers', () => {
|
||||
const { result } = renderViewportControls();
|
||||
|
||||
expect(screen.getByTestId('canvas-size').textContent).toBe('900x640');
|
||||
expect(screen.getByTestId('minimap-count').textContent).toBe('2');
|
||||
expect(screen.getByTestId('drop-point').textContent).toBe(
|
||||
'{"x":260,"y":190}',
|
||||
);
|
||||
const worldPoint = JSON.parse(
|
||||
screen.getByTestId('world-point').textContent ?? '{}',
|
||||
) as { x: number; y: number };
|
||||
expect(result.current.canvasSize).toEqual({ width: 900, height: 640 });
|
||||
expect(result.current.minimapModel?.layers).toHaveLength(2);
|
||||
expect(result.current.getCanvasDropPoint(260, 190)).toEqual({
|
||||
x: 260,
|
||||
y: 190,
|
||||
});
|
||||
const worldPoint = result.current.getCanvasPointFromClient(260, 190);
|
||||
expect(worldPoint.x).toBeCloseTo(1040);
|
||||
expect(worldPoint.y).toBeCloseTo(240);
|
||||
});
|
||||
|
||||
it('fits layers and zooms from the viewport center', () => {
|
||||
const captureCanvasHistory = vi.fn();
|
||||
const { result } = renderViewportControls({ captureCanvasHistory });
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'fit' }).click();
|
||||
result.current.fitLayers();
|
||||
});
|
||||
expectViewport(result.current.viewport, {
|
||||
x: 50,
|
||||
y: 170,
|
||||
scale: 1,
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(
|
||||
'50.00,170.00,1.00',
|
||||
);
|
||||
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'zoom center' }).click();
|
||||
result.current.updateScaleFromCenter(2);
|
||||
});
|
||||
expectViewport(result.current.viewport, {
|
||||
x: -350,
|
||||
y: 20,
|
||||
scale: 2,
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(
|
||||
'-350.00,20.00,2.00',
|
||||
);
|
||||
expect(captureCanvasHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -217,15 +167,20 @@ describe('useImageCanvasViewportControls', () => {
|
||||
},
|
||||
);
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
render(<ViewportHarness />);
|
||||
const viewportElement = screen.getByTestId('viewport-element');
|
||||
const { result, viewportElement } = renderViewportControls();
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(viewportElement, { deltaY: 120, clientX: 260, clientY: 190 });
|
||||
fireEvent.wheel(viewportElement, {
|
||||
deltaY: 120,
|
||||
clientX: 260,
|
||||
clientY: 190,
|
||||
});
|
||||
});
|
||||
expectViewport(result.current.viewport, {
|
||||
x: -260,
|
||||
y: -50,
|
||||
scale: 0.5,
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(
|
||||
'-260.00,-50.00,0.50',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(viewportElement, {
|
||||
@@ -235,23 +190,30 @@ describe('useImageCanvasViewportControls', () => {
|
||||
clientY: 190,
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(
|
||||
'-312.00,-74.00,0.55',
|
||||
);
|
||||
expectViewport(result.current.viewport, {
|
||||
x: -312,
|
||||
y: -74,
|
||||
scale: 0.55,
|
||||
});
|
||||
|
||||
const beforeDrag = result.current.viewport;
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag' }).click();
|
||||
result.current.updateViewportFromMinimapDrag(
|
||||
createMinimapDragState(result.current.minimapModel?.scale ?? 1),
|
||||
104,
|
||||
103,
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
rafCallbacks.shift()?.(performance.now());
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toContain('-');
|
||||
expect(result.current.viewport).not.toEqual(beforeDrag);
|
||||
|
||||
const beforeClick = screen.getByTestId('viewport').textContent;
|
||||
const beforeClick = result.current.viewport;
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap click' }).click();
|
||||
result.current.moveViewportFromMinimapPointer(100, 90);
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).not.toBe(beforeClick);
|
||||
expect(result.current.viewport).not.toEqual(beforeClick);
|
||||
});
|
||||
|
||||
it('coalesces minimap drag viewport updates into one animation frame', () => {
|
||||
@@ -263,31 +225,33 @@ describe('useImageCanvasViewportControls', () => {
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
render(<ViewportHarness />);
|
||||
const { result } = renderViewportControls();
|
||||
|
||||
const initialViewport = screen.getByTestId('viewport').textContent;
|
||||
const initialViewport = result.current.viewport;
|
||||
const dragState = createMinimapDragState(
|
||||
result.current.minimapModel?.scale ?? 1,
|
||||
);
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag twice' }).click();
|
||||
result.current.updateViewportFromMinimapDrag(dragState, 104, 103);
|
||||
result.current.updateViewportFromMinimapDrag(dragState, 112, 109);
|
||||
});
|
||||
|
||||
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(initialViewport);
|
||||
expect(result.current.viewport).toEqual(initialViewport);
|
||||
|
||||
act(() => {
|
||||
rafCallbacks.shift()?.(performance.now());
|
||||
});
|
||||
const coalescedViewport = screen.getByTestId('viewport').textContent;
|
||||
expect(coalescedViewport).not.toBe(initialViewport);
|
||||
const coalescedViewport = result.current.viewport;
|
||||
expect(coalescedViewport).not.toEqual(initialViewport);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag' }).click();
|
||||
result.current.updateViewportFromMinimapDrag(dragState, 104, 103);
|
||||
});
|
||||
act(() => {
|
||||
rafCallbacks.shift()?.(performance.now());
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).not.toBe(
|
||||
coalescedViewport,
|
||||
);
|
||||
expect(result.current.viewport).not.toEqual(coalescedViewport);
|
||||
});
|
||||
|
||||
it('flushes a pending minimap drag before the next animation frame', () => {
|
||||
@@ -301,23 +265,27 @@ describe('useImageCanvasViewportControls', () => {
|
||||
return rafCallbacks.length;
|
||||
},
|
||||
);
|
||||
render(<ViewportHarness />);
|
||||
const initialViewport = screen.getByTestId('viewport').textContent;
|
||||
const { result } = renderViewportControls();
|
||||
const initialViewport = result.current.viewport;
|
||||
const dragState = createMinimapDragState(
|
||||
result.current.minimapModel?.scale ?? 1,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'minimap drag twice' }).click();
|
||||
result.current.updateViewportFromMinimapDrag(dragState, 104, 103);
|
||||
result.current.updateViewportFromMinimapDrag(dragState, 112, 109);
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'flush minimap drag' }).click();
|
||||
result.current.flushMinimapViewportDrag();
|
||||
});
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(1);
|
||||
const flushedViewport = screen.getByTestId('viewport').textContent;
|
||||
expect(flushedViewport).not.toBe(initialViewport);
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledTimes(1);
|
||||
const flushedViewport = result.current.viewport;
|
||||
expect(flushedViewport).not.toEqual(initialViewport);
|
||||
|
||||
act(() => {
|
||||
rafCallbacks.shift()?.(performance.now());
|
||||
});
|
||||
expect(screen.getByTestId('viewport').textContent).toBe(flushedViewport);
|
||||
expect(result.current.viewport).toEqual(flushedViewport);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,9 @@ vi.mock('../../../../components/ResolvedAssetImage', () => ({
|
||||
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
|
||||
}));
|
||||
|
||||
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
|
||||
function buildSnapshot(
|
||||
overrides: Partial<BarkBattleSnapshot> = {},
|
||||
): BarkBattleSnapshot {
|
||||
return {
|
||||
phase: 'playing',
|
||||
uiState: 'playing',
|
||||
@@ -38,32 +40,62 @@ function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleS
|
||||
|
||||
describe('BarkBattleHud', () => {
|
||||
it('playing 阶段展示竖屏核心元素、倒计时和双方狗狗朝向', () => {
|
||||
render(<BarkBattleHud snapshot={buildSnapshot()} onMockBark={() => {}} onMockQuiet={() => {}} />);
|
||||
render(
|
||||
<BarkBattleHud
|
||||
snapshot={buildSnapshot()}
|
||||
onMockBark={() => {}}
|
||||
onMockQuiet={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('12.0s')).toBeTruthy();
|
||||
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
|
||||
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
|
||||
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
|
||||
expect(screen.getByLabelText(/玩家.*背对屏幕/u)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/对手.*面向屏幕/u)).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByRole('meter', { name: '声浪能量条' })
|
||||
.getAttribute('aria-valuenow'),
|
||||
).toBe('40');
|
||||
const arenaText = screen.getByLabelText('竖屏声浪竞技场').textContent ?? '';
|
||||
expect(arenaText.indexOf('对手 · 1')).toBeLessThan(arenaText.indexOf('你 · 3'));
|
||||
expect(arenaText.indexOf('对手 · 1')).toBeLessThan(
|
||||
arenaText.indexOf('你 · 3'),
|
||||
);
|
||||
});
|
||||
|
||||
it('energy 正负值会改变玩家侧和对手侧占比', () => {
|
||||
const { rerender } = render(<BarkBattleHud snapshot={buildSnapshot({ energy: 60 })} />);
|
||||
expect(screen.getByTestId('player-energy-fill').getAttribute('style')).toContain('width: 80%');
|
||||
const { rerender } = render(
|
||||
<BarkBattleHud snapshot={buildSnapshot({ energy: 60 })} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('player-energy-fill').getAttribute('style'),
|
||||
).toContain('width: 80%');
|
||||
|
||||
rerender(<BarkBattleHud snapshot={buildSnapshot({ energy: -60 })} />);
|
||||
expect(screen.getByTestId('opponent-energy-fill').getAttribute('style')).toContain('width: 80%');
|
||||
expect(
|
||||
screen.getByTestId('opponent-energy-fill').getAttribute('style'),
|
||||
).toContain('width: 80%');
|
||||
});
|
||||
|
||||
it('unsupported 不展示开始声控按钮,permission-denied 展示重试授权入口', () => {
|
||||
const { rerender } = render(
|
||||
<BarkBattleHud snapshot={buildSnapshot({ phase: 'unavailable', errorReason: 'unsupported' })} onStartMicrophone={() => {}} />,
|
||||
<BarkBattleHud
|
||||
snapshot={buildSnapshot({
|
||||
phase: 'unavailable',
|
||||
errorReason: 'unsupported',
|
||||
})}
|
||||
onStartMicrophone={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<BarkBattleHud snapshot={buildSnapshot({ phase: 'unavailable', errorReason: 'permission-denied' })} onStartMicrophone={() => {}} />,
|
||||
<BarkBattleHud
|
||||
snapshot={buildSnapshot({
|
||||
phase: 'unavailable',
|
||||
errorReason: 'permission-denied',
|
||||
})}
|
||||
onStartMicrophone={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
|
||||
});
|
||||
@@ -78,8 +110,11 @@ describe('BarkBattleHud', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
|
||||
expect(document.querySelector('img[src="https://example.test/opponent.png"]')).toBeTruthy();
|
||||
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
|
||||
const player = screen.getByLabelText(/玩家.*背对屏幕/u);
|
||||
const opponent = screen.getByLabelText(/对手.*面向屏幕/u);
|
||||
expect(player.textContent).toContain('你 · 3');
|
||||
expect(player.textContent).not.toContain('🐕');
|
||||
expect(opponent.textContent).toContain('对手 · 1');
|
||||
expect(opponent.textContent).not.toContain('🐶');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user