diff --git a/docs/README.md b/docs/README.md index 33948359c..0ceca3f52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 90b0f6654..7210ddce7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.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 互斥,多实例下不能防重复。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b621929fb..83c3108aa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 漏玩法会导致运行内容和标题作者错位 - 现象:移动端推荐页进入跳一跳或敲木鱼等作品时,游戏运行内容已经切到当前作品,但下方标题、作者和头像仍显示第一条拼图或其它推荐作品。 diff --git a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md new file mode 100644 index 000000000..a2a7667a8 --- /dev/null +++ b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md @@ -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,例如 ``、`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`。 diff --git a/src/components/auth/BindPhoneScreen.test.tsx b/src/components/auth/BindPhoneScreen.test.tsx index 773b0cd92..c5989c9d9 100644 --- a/src/components/auth/BindPhoneScreen.test.tsx +++ b/src/components/auth/BindPhoneScreen.test.tsx @@ -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'); }); diff --git a/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx b/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx index 9773e2429..a9f162eeb 100644 --- a/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx +++ b/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx @@ -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(); }); diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx index 3e714d965..aab1ed80f 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -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[0]> = {}, + ) { const switchTool = vi.fn(); const specToolWrapRef = createRef(); const musicToolWrapRef = createRef(); const publicationToolWrapRef = createRef(); - render( + const view = render( { 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(); - const musicToolWrapRef = createRef(); - const publicationToolWrapRef = createRef(); - const renderToolbar = (effectiveTool: Parameters< + function renderToolbarView( + effectiveTool: Parameters< typeof ImageCanvasBottomToolbarView - >[0]['effectiveTool']) => ( + >[0]['effectiveTool'], + refs: { + specToolWrapRef: RefObject; + musicToolWrapRef: RefObject; + publicationToolWrapRef: RefObject; + }, + ) { + return ( ); + } - 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(); - const musicToolWrapRef = createRef(); - const publicationToolWrapRef = createRef(); - render( - , - ); - - 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(); - const musicToolWrapRef = createRef(); - const publicationToolWrapRef = createRef(); - render( - , - ); + 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(); - const musicToolWrapRef = createRef(); - const publicationToolWrapRef = createRef(); - render( - , + 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'); }); }); diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index b933c82cc..1a504564a 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -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 ( -
- {activeId} - {activePrompt} - {inactiveIds} - {allPrompts} - - {String( - dialogs.getGeneratingDialogPlaceholder( - dialogs.activeCanvasGenerationDialog ?? { - mode: 'generate', - prompt: 'fallback', - status: 'idle', - }, - )?.x ?? '-', - )} - - - - - - - - - - -
- ); -} - describe('useCanvasGenerationDialogs', () => { it('archives, activates, updates, and removes canvas generation dialogs', () => { const onActivate = vi.fn(); - render(); - - 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(); + 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(); + 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, + }), + ]); }); }); diff --git a/src/components/image-editor/useImageCanvasEditorChrome.test.tsx b/src/components/image-editor/useImageCanvasEditorChrome.test.tsx index 03f8da9fb..e237641f2 100644 --- a/src/components/image-editor/useImageCanvasEditorChrome.test.tsx +++ b/src/components/image-editor/useImageCanvasEditorChrome.test.tsx @@ -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 ( -
- {chrome.projectTitle} - {chrome.projectRenameValue} - {String(chrome.isRenamingProject)} - {String(chrome.isProjectRenameSaving)} - {chrome.projectRenameError ?? '-'} - {chrome.activeSidebarPanel ?? '-'} - {chrome.activeTool} - {String(chrome.isZoomMenuOpen)} - - {String(chrome.isBackgroundSettingsOpen)} - - {String(chrome.isMinimapOpen)} - - {chrome.canvasBackgroundColor} - - - {chrome.canvasBackgroundHexValue} - - - - - - - - - - - - - - - - - - -
- ); +} = {}) { + return renderHook(() => useImageCanvasEditorChrome({ openEditorLoginModal })); } describe('useImageCanvasEditorChrome', () => { @@ -135,15 +40,23 @@ describe('useImageCanvasEditorChrome', () => { resources: [], updatedAt: '2026-06-17T00:00:00.000Z', }); - render(); + 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(); + 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(); - - 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); }); }); diff --git a/src/components/image-editor/useImageCanvasViewportControls.test.tsx b/src/components/image-editor/useImageCanvasViewportControls.test.tsx index 90f04fb43..c9f3baf14 100644 --- a/src/components/image-editor/useImageCanvasViewportControls.test.tsx +++ b/src/components/image-editor/useImageCanvasViewportControls.test.tsx @@ -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 { @@ -52,159 +51,110 @@ function setElementBox( }) as DOMRect; } -function ViewportHarness({ - captureCanvasHistory = vi.fn(), -}: { - captureCanvasHistory?: () => void; -}) { - const viewportRef = useRef(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 ( -
-
{ - viewportRef.current = element; - if (element) { - setElementBox(element, { width: 900, height: 640 }); - } - }} - data-testid="viewport-element" - /> -
{ - if (element) { - setElementBox(element, { - left: 20, - top: 30, - width: 160, - height: 120, - }); - } - }} - /> - - {controls.viewport.x.toFixed(2)},{controls.viewport.y.toFixed(2)}, - {controls.viewport.scale.toFixed(2)} - - - {controls.canvasSize.width}x{controls.canvasSize.height} - - - {JSON.stringify(controls.getCanvasDropPoint(260, 190))} - - - {JSON.stringify(controls.getCanvasPointFromClient(260, 190))} - - - {controls.minimapModel?.layers.length ?? 0} - - - - - - - -
+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( - , - ); + 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(); - 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(); + 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(); - 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); }); }); diff --git a/src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx b/src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx index bd51e5fe6..0d1f35cd0 100644 --- a/src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx +++ b/src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx @@ -17,7 +17,9 @@ vi.mock('../../../../components/ResolvedAssetImage', () => ({ }) => {alt, })); -function buildSnapshot(overrides: Partial = {}): BarkBattleSnapshot { +function buildSnapshot( + overrides: Partial = {}, +): BarkBattleSnapshot { return { phase: 'playing', uiState: 'playing', @@ -38,32 +40,62 @@ function buildSnapshot(overrides: Partial = {}): BarkBattleS describe('BarkBattleHud', () => { it('playing 阶段展示竖屏核心元素、倒计时和双方狗狗朝向', () => { - render( {}} onMockQuiet={() => {}} />); + render( + {}} + 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(); - expect(screen.getByTestId('player-energy-fill').getAttribute('style')).toContain('width: 80%'); + const { rerender } = render( + , + ); + expect( + screen.getByTestId('player-energy-fill').getAttribute('style'), + ).toContain('width: 80%'); rerender(); - 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( - {}} />, + {}} + />, ); expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull(); rerender( - {}} />, + {}} + />, ); 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('🐶'); }); });