收紧 React 测试契约

将画布相关 hook 测试改为直接验证公开返回值

移除组件测试中对 class、图标和测试专用 DOM 状态的脆弱断言

新增 React 组件测试准则并同步项目记忆
This commit is contained in:
2026-06-26 19:37:28 +08:00
parent 1924b86b92
commit ec34f85006
11 changed files with 625 additions and 741 deletions
+2
View File
@@ -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 互斥,多实例下不能防重复。
+13 -4
View File
@@ -5,7 +5,6 @@
## 记录格式
```md
## 问题标题
- 现象:看到什么错误或异常行为
@@ -15,6 +14,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## React 测试因内部状态或实现细节正常重构就碎
- 现象:修改组件结构、按钮排序、图标库 class、提示文案或 hook 内部状态名后,React 测试大量失败,但真实用户流程和对外契约没有变化。
- 原因:测试把 `data-testid` 仪表盘、`textContent` 拼接状态、完整对象 / 数组顺序、图标 class 或长文案当成契约;这些断言绑定的是实现形状,不是用户行为或稳定边界。
- 处理:按 `React 组件测试准则` 重写到更稳定的层级。用户流程测试断言 role / label / URL / 弹窗 / callbackhook 逻辑用 `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`
+6 -15
View File
@@ -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('🐶');
});
});