From f5a3ef82410abfc2a5085f6e51dcdedc32b08f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 19:51:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF=E7=9A=84?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=8E=BB=E6=8E=89=E6=81=92=E7=9C=9F=E7=9A=84?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E6=80=A7=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getBy* / findBy* 找不到元素就抛,外面那层 expect(...).not.toBeNull() 永远不会失败:删掉它,等待出现改用 findBy*,需要对照的用 queryBy* + toBeNull() - 能写成行为的就别写成存在:有脚本时点按钮行的「适配」看 adapt 是否被调用;读失败时同样点一下证明入口没变成摆设;切 tab 后补一条「卡片没有重新长回来」的负向断言 - artifactsPanel 三个目标改用 role=button(可点入口,不是三行说明文字),保留 querySelector 那几条(它们真的会返回 null) - team-conventions 记下这条断言口径,并给「不建立固定频率轮询」补上导出面板对 .export/ 的显式例外(那两件事实没有事件源,只能现读) --- .../tests/artifactsPanel.test.tsx | 16 +++++--- .../tests/xhsMinitoolExport.test.tsx | 37 +++++++++++-------- ...’】导出产物面板与小红书小工具导出-2026-10-05.md | 1 + .../shared-memory/team-conventions.md | 3 +- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/artifactsPanel.test.tsx b/apps/ai-game-creator-shell/tests/artifactsPanel.test.tsx index 76215a64c..30b178c3f 100644 --- a/apps/ai-game-creator-shell/tests/artifactsPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/artifactsPanel.test.tsx @@ -31,20 +31,24 @@ describe('导出产物面板', () => { it('首屏用大卡片展示三个导出目标', () => { renderPanel(); - expect(screen.getByText('陶泥儿')).not.toBeNull(); - expect(screen.getByText('小红书小工具')).not.toBeNull(); - expect(screen.getByText('TapTap H5 小游戏')).not.toBeNull(); + // 三个目标都以可点入口出现(`getBy*` 找不到就抛,不必再包一层恒真的断言)。 + screen.getByRole('button', { name: /陶泥儿/u }); + screen.getByRole('button', { name: /小红书小工具/u }); + screen.getByRole('button', { name: /TapTap H5 小游戏/u }); }); it('点进目标后卡片收成顶部 tab,切到空目标只换 tab 页', () => { renderPanel(); fireEvent.click(screen.getByRole('button', { name: /陶泥儿/u })); - expect(screen.getByRole('tablist', { name: '导出目标' })).not.toBeNull(); - expect(screen.getByText('陶泥儿导出页尚未接入')).not.toBeNull(); + screen.getByRole('tablist', { name: '导出目标' }); + screen.getByText('陶泥儿导出页尚未接入'); + // 切到另一个目标只换 tab 页,卡片不会重新长回来。 + expect(screen.queryByRole('button', { name: /陶泥儿/u })).toBeNull(); fireEvent.click(screen.getByRole('tab', { name: /TapTap H5 小游戏/u })); - expect(screen.getByText('TapTap H5 小游戏导出页尚未接入')).not.toBeNull(); + screen.getByText('TapTap H5 小游戏导出页尚未接入'); + expect(screen.queryByText('陶泥儿导出页尚未接入')).toBeNull(); }); it('小红书 tab 挂载的是主聊天同一个对话盒', () => { diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index fa976a559..32288cc7b 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -172,9 +172,7 @@ describe('面板左半', () => { await waitFor(() => expect(writeText).toHaveBeenCalledWith('小工具')); // 成功态也写进无障碍名,用户与读屏都看得到「已复制」。 - await waitFor(() => - expect(screen.getByRole('button', { name: '已复制名称' })).not.toBeNull(), - ); + await screen.findByRole('button', { name: '已复制名称' }); }); it('复制失败不静默吞掉,按钮自己说一声', async () => { @@ -183,11 +181,7 @@ describe('面板左半', () => { fireEvent.click(screen.getByRole('button', { name: '复制名称' })); - await waitFor(() => - expect( - screen.getByRole('button', { name: '复制失败名称' }), - ).not.toBeNull(), - ); + await screen.findByRole('button', { name: '复制失败名称' }); }); it('icon 与 zip 的下载都走原生保存对话框与素材保存命令', async () => { @@ -243,25 +237,37 @@ describe('面板左半', () => { renderPane(withScript); fireEvent.click(screen.getByRole('button', { name: '导出' })); expect(withScript.runExport).toHaveBeenCalledTimes(1); - // 有脚本时那颗按钮回到按钮行,卡片不再出现。 - expect(screen.getByRole('button', { name: '适配' })).not.toBeNull(); + // 有脚本时卡片不再出现,适配入口回到按钮行;它仍然能入队,不是个摆设。 expect( screen.queryByRole('button', { name: '交给 code agent 适配' }), ).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '适配' })); + expect(withScript.adapt).toHaveBeenCalledTimes(1); }); it('注册表还没读回来时不下「还没适配」的结论', () => { renderPane(fakeMinitool({ form: null, loading: true, hasScript: false })); + // 读取中给一行状态,但不下「还没适配」的结论、也不摆那张自带入口的卡片。 + screen.getByText('正在读取导出注册表…'); expect(screen.queryByText(missingScriptTip())).toBeNull(); - expect(screen.getByText('正在读取导出注册表…')).not.toBeNull(); + expect( + screen.queryByRole('button', { name: '交给 code agent 适配' }), + ).toBeNull(); }); it('读失败时保留按钮行里的适配入口,不摆未适配卡片', () => { - renderPane(fakeMinitool({ form: null, loading: false, hasScript: false })); + const minitool = fakeMinitool({ + form: null, + loading: false, + hasScript: false, + }); + renderPane(minitool); expect(screen.queryByText(missingScriptTip())).toBeNull(); - expect(screen.getByRole('button', { name: '适配' })).not.toBeNull(); + // 读失败不等于入口失效:那颗按钮还在,点下去照样把任务交给 agent。 + fireEvent.click(screen.getByRole('button', { name: '适配' })); + expect(minitool.adapt).toHaveBeenCalledTimes(1); }); it('冲突面板逐字段二选一,按选择保存的合并结果交回上层', () => { @@ -295,9 +301,8 @@ describe('面板左半', () => { }); renderPane(neverAdapted); - expect( - screen.getByText('宿主找过:package.json、game/package.json'), - ).not.toBeNull(); + // 现场照载荷原文写出来(宿主找过哪些 package.json 由载荷决定,前端不自己编)。 + screen.getByText('宿主找过:package.json、game/package.json'); expect( screen.queryByRole('button', { name: '让 code agent 修' }), ).toBeNull(); diff --git a/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md b/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md index f52da7fd8..cc01faf88 100644 --- a/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md +++ b/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md @@ -58,6 +58,7 @@ - `5e2426c8a` 导出收尾判据抽成 `conclude(...)` 并补 5 例确定性用例(非零退出 / 超时 / 产物缺失 / 空产物 / 成功),同时去掉 `XHSMiniToolExportRunResult.exitCode` 这个恒为 0 的死字段;`463341c4b` 导出成功后把脚本输出尾部报给用户;`b3be3ee71` 证据口径修正;`5dd158ee1` 补默认 ignore 的真实起进程用例。 - `1b7741ac6` 适配与修复指令补上「包根在 game/ 时落点写成 `../.export/xhs-minitool.zip`」,skill 契约同步(manifest `2026-08-26.42`);`f8ed4e898` 保存对话框的默认文件名收进下载助手并复用既有取文件名工具;`90f651ddf` 刷新时先撤掉待触发的自动保存(附回归用例;该行为已被下面的自动刷新取代——轮询不再撤掉待保存的输入,因为「磁盘为准」的刷新动作没有了)。 - `ac666daad` 之后:未适配提示改成带入队按钮的卡片、失败卡片按变体分派适配/修/重试、提示只在注册表读回来之后出现(同一时刻只有一张卡片、一个入口)。 +- 导出面板的用例去掉恒真的存在性断言(`getBy*` 本来就抛,不再包 `expect(...).not.toBeNull()`;等待出现改用 `findBy*`,能写成行为断言的就点一下按钮看回调),并把这条口径写进 team-conventions。 - layout 的用例重写:原来那条 `assert_eq!(artifact_relative_path(), format!(...))` 只是把实现抄了一遍,现改成 4 条只钉关系的用例(相对路径与宿主 `PathBuf` 指向同一文件、不许跑出 `.export/` 且不出现反斜杠、注册表与产物共用一个项目根锚定的 flat 目录、脚本名保住 `build:<后缀>` 形式);逐条反证过会红,`export::` 50 → 53 例。 - `dffe4de55` 导出链路按 ADR 收口(策略拒绝与宿主故障拆成两个变体、输出尾部只回原文与省略量);`df3a6826b` 前端失败通道按 ADR 分流(宿主故障与未分类拒绝原样抛出并去重)与复制失败可见反馈。 - 复制按钮改用 Tauri 剪贴板插件(原 `navigator.clipboard` 在 WebView 里可能根本没有,失败还会静默吞掉),失败态在按钮上可见。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 8fb88c73c..b857ba631 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -43,6 +43,7 @@ - AGC 模板库灰度复用 `agc:template-library`:未配置关闭,已配置时遵循现有灰度启停、用户 ID/标签和比例规则;服务端返回权威结论,客户端入口和原生清单/下载/建项均执行门禁,主体切换丢弃旧异步结果。公开 OSS 不是保密边界,已创建项目不受影响。 - 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 +- 断言只写在能失败的地方:`getBy*` / `findBy*` 找不到就抛,不要再包一层 `expect(...).not.toBeNull()` / `toBeDefined()`(那种写法恒真,还让每条查询多一层噪声);等元素出现用 `findBy*` 或 `await waitFor(() => screen.getBy*())`,负向用 `queryBy*` + `toBeNull()`。同理不写复述实现的断言(例如把被测函数的表达式在测试里再抄一遍),改为钉关系、边界或行为,并能逐条反证会红。 - 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本。 - Agent 可见内容直接描述当前任务、输入和成功条件,细节按调用需要提供。 - AGC 外置智能体提示词只写模型必须遵守的指令与契约(工具名与参数、调用顺序、禁止项、失败处理、产物要求),不写客户端/宿主怎么实现:内部预算字段与限额、内部状态机与枚举、执行器锁与沙箱技术栈、进程/插件部署、注入与投影管线都属于宿主实现,一律改写成对应行为要求或删除。 @@ -52,7 +53,7 @@ - 后端遵循 `module-*`、`spacetime-module`、`spacetime-client`、`api-server`、`platform-*`、`shared-contracts` 的现役边界。 - 前端只负责表现、交互和临时 UI 状态;正式状态来自后端投影、API 或持久化契约。 - AGC 渲染层是离线前端:平台接口、OSS 素材直传、Provider、错误上报与账户/认证的网络 IO 全部在 Rust 的 reqwest facade,渲染层不得再出现 `fetch(` / `XMLHttpRequest` / `EventSource` / `sendBeacon(` / `new WebSocket(` 或 `@tauri-apps/plugin-http`;客户端窗口 capability 也不得授予 `http:default`。`check:native-shells --groups=contract` 的 `ai-game-creator-shell-user-dev-boundary` 用负向门禁钉住这条,改动渲染层网络边界必须同批改该门禁与 `build-release.test.mjs`。 -- AGC 工作区的正式状态变化走 Rust 事件 + 一次受控快照读取,不建立固定频率轮询(纯 UI 计时器、拖拽重复器与动画 tick 除外):渲染层先订阅再读快照,事件重复或内容未变时保持数组身份,卸载后迟到事件不写回。Direct 活动回合的唯一事实源是 Direct 线程管理器的活动回合快照(`list_direct_active_turns` 只读它;登记、进度内容变化与收口各广播一次 `game-creator-direct-active-turns-changed`),渲染层与 Rust 都不得再引第二份注册表。 +- AGC 工作区的正式状态变化走 Rust 事件 + 一次受控快照读取,不建立固定频率轮询(纯 UI 计时器、拖拽重复器与动画 tick 除外;导出面板对 `.export/` 注册表与 npm 脚本的 2 秒重读是显式例外——那两件事实由用户项目和 agent 直接改文件,没有事件源,只能现读):渲染层先订阅再读快照,事件重复或内容未变时保持数组身份,卸载后迟到事件不写回。Direct 活动回合的唯一事实源是 Direct 线程管理器的活动回合快照(`list_direct_active_turns` 只读它;登记、进度内容变化与收口各广播一次 `game-creator-direct-active-turns-changed`),渲染层与 Rust 都不得再引第二份注册表。 - AGC 维护态(平台 `503` 且命中 `MAINTENANCE` 或「维护」)由 Rust 在平台请求的错误分支经 `platform_maintenance::watch_platform_response` 分类并广播 `genarrative-client-maintenance-detected`;渲染层只订阅并打开唯一的「系统维护中」弹窗,不自行解析 HTTP,业务面板各自的错误文案保持不变。 - 对已明确退役且无现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。公开契约、持久化数据和正式迁移按实际需求保留最小兼容及对应测试。 - 修改 `/api/external/v1` 时,同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 与契约测试。