From f497ae23e0ddf3267ae5d82b459aeb5748975c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 14:32:00 +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=E8=B7=9F=E7=9D=80=E8=A1=8C=E4=B8=BA=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `xhsMinitoolExport`:删掉对生成文案的恒真断言(`toContain('…')` 那批)与只为它们存在的用例,改成行为断言与 IPC 调用断言; - 新增「帮我填」两条:落盘一定发生在把指令交给 agent 之前(顺序用调用表钉住)、表单不合法也照样交出去(agent 正是来收拾这份表单的); - 打包那颗按钮的用例按新行为改:没有脚本时点下去要开适配弹窗,而不是变灰; - `xhsMinitoolContract` 只留一条:工作目录、脚本名、注册表与产物路径与 Rust 权威逐条一致。 --- .../tests/xhsMinitoolContract.test.ts | 22 - .../tests/xhsMinitoolExport.test.tsx | 987 ++++++++++++++---- 2 files changed, 794 insertions(+), 215 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolContract.test.ts b/apps/ai-game-creator-shell/tests/xhsMinitoolContract.test.ts index 0293bbaf2..e9663c0ef 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolContract.test.ts +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolContract.test.ts @@ -16,13 +16,9 @@ import { describe, expect, it } from 'vitest'; import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, - XHS_MINITOOL_COPIED_SCRIPTS, XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH, - XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH, XHS_MINITOOL_REGISTRY_RELATIVE_PATH, XHS_MINITOOL_SCRIPT_NAME, - XHS_MINITOOL_VALIDATE_SCRIPT_RELATIVE_PATH, - XHS_MINITOOL_VITE_CONFIG_RELATIVE_PATH, } from '../src/view/project-development/export/state/xhsMinitoolInstruction'; import { repoPath } from './repoPath'; @@ -59,22 +55,4 @@ describe('小红书小工具的契约常量', () => { `${dir}/${rustStringConst(layout, 'ARTIFACT_FILE_NAME')}`, ); }); - - it('拷进项目的三个脚本都落在工作目录里', () => { - const destinations: Record = { - 'scripts/pack.mjs': XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH, - 'scripts/validate.mjs': XHS_MINITOOL_VALIDATE_SCRIPT_RELATIVE_PATH, - 'scripts/vite.config.xhs-minitool.mjs': - XHS_MINITOOL_VITE_CONFIG_RELATIVE_PATH, - }; - // 拷贝清单就是把上面这三个落点排成 agent 要执行的动作,它不该自己再有一套路径。 - expect( - XHS_MINITOOL_COPIED_SCRIPTS.map(({ from, to }) => ({ from, to })), - ).toEqual(Object.entries(destinations).map(([from, to]) => ({ from, to }))); - for (const { to } of XHS_MINITOOL_COPIED_SCRIPTS) { - expect(to.startsWith(`${XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH}/`)).toBe( - true, - ); - } - }); }); diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 4a4ea6d4a..5caf51eb3 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -20,22 +20,26 @@ import { } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - XhsMinitoolExportState, - XhsMinitoolFailureState, -} from '../src/view/project-development/export/state/useXhsMinitoolExport'; +import type { TauriInvoke } from '../src/app/types'; +import type { XhsMinitoolExportState } from '../src/view/project-development/export/state/useXhsMinitoolExport'; import { useXhsMinitoolExport } from '../src/view/project-development/export/state/useXhsMinitoolExport'; -import { describeXhsMinitoolFailure } from '../src/view/project-development/export/state/xhsMinitoolFailure'; import { - buildXhsMinitoolAdaptationInstruction, - buildXhsMinitoolRepairInstruction, + asXhsMinitoolBuildFailure, + asXhsMinitoolReadFailure, + asXhsMinitoolSaveFailure, + readXhsMinitoolExport, + runXhsMinitoolExportBuild, + saveXhsMinitoolExportForm, +} from '../src/view/project-development/export/state/xhsMinitoolApi'; +import type { XhsMinitoolCardFailure } from '../src/view/project-development/export/state/xhsMinitoolFailure'; +import { + classifyXhsMinitoolFailure, + describeXhsMinitoolFailure, + XhsMinitoolFailureRepairability, +} from '../src/view/project-development/export/state/xhsMinitoolFailure'; +import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, - XHS_MINITOOL_COPIED_SCRIPTS, - XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH, - XHS_MINITOOL_REGISTRY_RELATIVE_PATH, - XHS_MINITOOL_REGISTRY_SHAPE, XHS_MINITOOL_SCRIPT_NAME, - XHS_MINITOOL_SKILL_NAME, } from '../src/view/project-development/export/state/xhsMinitoolInstruction'; import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane'; import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; @@ -43,12 +47,45 @@ import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() })); // 剪贴板走 Tauri 插件(`navigator.clipboard` 在 WebView 里可能根本没有)。 vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ writeText: vi.fn() })); +// 图标选择走公共 AssetImporter:这里只钉「打开的是它、导入回调把路径写回表单」这条接线, +// 它内部的文件树与两个来源由 `tests/assetImporter.test.ts` 自己覆盖。 +vi.mock('../src/components/AssetImporter', () => ({ + AssetImporter: ({ + open, + settings, + onImport, + }: { + open: boolean; + settings: { ariaLabel: string }; + onImport: ( + assets: { id: string; localPath: string; assetKind: null }[], + ) => void; + }) => + open ? ( +
+ +
+ ) : null, +})); const PROJECT_PATH = '/tmp/export-panel-project'; -/** 「还没适配」提示卡片那句话:匹配要跟着契约常量走,别在用例里再抄一份脚本名。 */ -function missingScriptTip(): RegExp { - return new RegExp(`还没有 .${XHS_MINITOOL_SCRIPT_NAME}. 脚本`, 'u'); +/** 「还没配置好导出」弹窗的标题:面板文案不出现脚本名,匹配也跟着用户文案走。 */ +function notConfiguredTip(): RegExp { + return /这个项目还没有配置好导出/u; } const CONTENT_HASH = 'hash-1'; @@ -70,56 +107,17 @@ afterEach(() => { }); describe('交给 code agent 的指令', () => { - it('适配指令写清三条硬契约与要显式拷贝的示例脚本', () => { - const instruction = buildXhsMinitoolAdaptationInstruction(PROJECT_PATH); - - // 期望值一律从契约常量推:这里要钉的是「正文有没有把契约写进去」,不是把常量的值再抄一遍 - // (值对不对由 xhsMinitoolContract 对 Rust 权威的那条用例负责)。 - expect(instruction).toContain(PROJECT_PATH); - expect(instruction).toContain(XHS_MINITOOL_SCRIPT_NAME); - expect(instruction).toContain(XHS_MINITOOL_ARTIFACT_RELATIVE_PATH); - expect(instruction).toContain(XHS_MINITOOL_REGISTRY_RELATIVE_PATH); - expect(instruction).toContain(XHS_MINITOOL_REGISTRY_SHAPE); - expect(instruction).toContain(XHS_MINITOOL_SKILL_NAME); - // 示例脚本必须逐条被显式要求拷贝,只"参考 skill"会退化成 agent 自由发挥。 - expect(XHS_MINITOOL_COPIED_SCRIPTS.length).toBeGreaterThan(0); - for (const { from, to } of XHS_MINITOOL_COPIED_SCRIPTS) { - expect(instruction).toContain(`${from} → ${to}`); - } - // pack.mjs 打包前会预检 --vite-built-dir:白名单外的文件让它整体失败,不会再就地删除。 - expect(instruction).toContain('--vite-built-dir'); - // 包根在 game/ 时落点要回退一层,否则 zip 落在 game/.export/ 下,宿主必然报产物缺失。 - expect(instruction).toContain(`../${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`); - }); - - it('修复指令带上宿主的失败现场', () => { - const instruction = buildXhsMinitoolRepairInstruction(PROJECT_PATH, { - type: 'artifactMissing', - path: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, - }); - - expect(instruction).toContain(PROJECT_PATH); - expect(instruction).toContain( - `${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH} 不在`, - ); - expect(instruction).toContain(XHS_MINITOOL_SCRIPT_NAME); - expect(instruction).toContain(`../${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`); - expect(instruction).toContain(XHS_MINITOOL_PACK_SCRIPT_RELATIVE_PATH); - }); - - it('每个失败变体都有一句人话与下一步', () => { + it('每个失败变体只产出一句给用户的人话', () => { expect( describeXhsMinitoolFailure({ type: 'buildScriptMissing', script: XHS_MINITOOL_SCRIPT_NAME, searched: ['package.json', 'game/package.json'], }), - ).toEqual({ - // 文案里的脚本名来自宿主载荷,不是前端再抄一遍。 - message: `项目里还没有 ${XHS_MINITOOL_SCRIPT_NAME} 脚本——这就是「还没适配」的判据。`, - detail: '宿主找过:package.json、game/package.json', - action: 'adapt', - }); + ).toBe( + // 卡片只说「还没配置好导出」,不暴露脚本名,也不列宿主找过哪些目录。 + '这个项目还没有配置好导出。先让陶泥儿帮你调通一次,之后就能直接打包了。', + ); expect( describeXhsMinitoolFailure({ type: 'commandFailed', @@ -127,40 +125,88 @@ describe('交给 code agent 的指令', () => { outputTail: 'vite build failed', omittedCharacters: 0, }), - ).toMatchObject({ action: 'repair', detail: 'vite build failed' }); - // 截断是宿主给的事实,提示那句话在这里拼:载荷原文里不含任何宿主写的说明。 + ).toBe('导出中途出错了,没能生成代码包。'); + // 没启动起来(宿主没给退出码)与跑到一半失败是两句不同的话:用户能做的处置不一样。 expect( describeXhsMinitoolFailure({ type: 'commandFailed', - exitCode: 1, - outputTail: '最后一行报错', - omittedCharacters: 4096, - }).detail, - ).toBe('(日志过长,已省略前 4096 个字符)\n最后一行报错'); + exitCode: null, + outputTail: '', + omittedCharacters: 0, + }), + ).toBe('导出没能启动,可能是运行环境的问题。'); // 策略拒绝是预期拒绝:宿主只给点位,句子在前端,且不进错误池。 expect( describeXhsMinitoolFailure({ type: 'commandDenied', commandId: 'command.exec', }), - ).toEqual({ - message: '项目权限策略拒绝执行:command.exec', - detail: '', - action: 'retry', - }); + ).toBe('当前项目的权限设置不允许执行这次导出。'); expect( describeXhsMinitoolFailure({ type: 'exportUnavailable', cause: '项目根不可用:找不到目录', - }).action, - ).toBe('retry'); + }), + ).toBe('这次没能执行导出,请稍后重试。'); expect( describeXhsMinitoolFailure({ - type: 'formInvalid', - field: 'iconPath', - reason: 'icon 只支持 png、jpg、jpeg,当前是 .webp', - }).message, - ).toContain('icon 文件'); + type: 'registryMalformed', + cause: 'JSON 解析失败', + }), + ).toBe('导出信息的内容被改坏了,读不出来。可以让陶泥儿重新生成一份。'); + expect( + describeXhsMinitoolFailure({ + type: 'artifactMissing', + path: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + }), + ).toBe('导出跑完了,但没有生成代码包。可以让陶泥儿看看。'); + }); + + it('分类的判据是要改的东西在不在项目代码里', () => { + // 项目代码 / 脚本 / 产物 / 导出信息文件里的毛病:只有 code agent 改得动。 + expect( + classifyXhsMinitoolFailure({ + type: 'buildScriptMissing', + script: XHS_MINITOOL_SCRIPT_NAME, + searched: ['package.json'], + }), + ).toBe(XhsMinitoolFailureRepairability.CodeAgentRepairable); + expect( + classifyXhsMinitoolFailure({ + type: 'registryMalformed', + cause: 'JSON 解析失败', + }), + ).toBe(XhsMinitoolFailureRepairability.CodeAgentRepairable); + expect( + classifyXhsMinitoolFailure({ + type: 'commandFailed', + exitCode: 1, + outputTail: '', + omittedCharacters: 0, + }), + ).toBe(XhsMinitoolFailureRepairability.CodeAgentRepairable); + expect( + classifyXhsMinitoolFailure({ + type: 'artifactMissing', + path: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + }), + ).toBe(XhsMinitoolFailureRepairability.CodeAgentRepairable); + + // 要改的东西不在项目代码里:叫 agent 来也没得改。 + // 用户在 .agent/policy.json 里拉黑了 command.exec:这是用户自己的配置,且 agent 受同一条策略约束。 + expect( + classifyXhsMinitoolFailure({ + type: 'commandDenied', + commandId: 'command.exec', + }), + ).toBe(XhsMinitoolFailureRepairability.OnlyHumanRepairable); + expect( + classifyXhsMinitoolFailure({ + type: 'exportUnavailable', + cause: '项目根不可用:找不到目录', + }), + ).toBe(XhsMinitoolFailureRepairability.OnlyHumanRepairable); + // `formInvalid` / `saveConflict` 不在这张表里:它们只出现在保存命令的失败表上,各有专属 UI。 }); }); @@ -168,106 +214,272 @@ describe('面板左半', () => { it('复制按钮把字段值写进剪贴板', async () => { renderPane(); - fireEvent.click(screen.getByRole('button', { name: '复制名称' })); + fireEvent.click(screen.getByRole('button', { name: '复制小工具名称' })); await waitFor(() => expect(writeText).toHaveBeenCalledWith('小工具')); // 成功态也写进无障碍名,用户与读屏都看得到「已复制」。 - await screen.findByRole('button', { name: '已复制名称' }); + await screen.findByRole('button', { name: '已复制小工具名称' }); }); it('复制失败不静默吞掉,按钮自己说一声', async () => { vi.mocked(writeText).mockRejectedValue(new Error('剪贴板不可用')); renderPane(); - fireEvent.click(screen.getByRole('button', { name: '复制名称' })); + fireEvent.click(screen.getByRole('button', { name: '复制小工具名称' })); - await screen.findByRole('button', { name: '复制失败名称' }); + await screen.findByRole('button', { name: '复制失败小工具名称' }); + }); + + it('路径不给复制入口:图标与代码包都只给下载', () => { + renderPane(fakeMinitool({ artifactReady: true })); + + // 图标是文件、代码包是压缩包,把项目内路径复制出去没有意义。 + expect(screen.queryByRole('button', { name: '复制图标' })).toBeNull(); + expect(screen.queryByRole('button', { name: '复制代码包位置' })).toBeNull(); + expect(screen.queryByText(XHS_MINITOOL_ARTIFACT_RELATIVE_PATH)).toBeNull(); + // 名称与介绍仍可复制:它们是要粘贴到小红书上传页的文字。 + screen.getByRole('button', { name: '复制小工具名称' }); + screen.getByRole('button', { name: '复制一句话介绍' }); + // 两个下载入口都还在。 + screen.getByRole('button', { name: '下载图标文件' }); + screen.getByRole('button', { name: '下载代码包' }); + }); + + it('代码包只在打包成功后给出下载入口', () => { + // 还没打过:只有「打包」,「下载代码包」不出现(不给一个下不了的按钮)。 + renderPane(); + screen.getByRole('button', { name: '打包' }); + expect(screen.queryByRole('button', { name: '下载代码包' })).toBeNull(); + + cleanup(); + renderPane(fakeMinitool({ artifactReady: true })); + screen.getByRole('button', { name: '下载代码包' }); }); it('icon 与 zip 的下载都走原生保存对话框与素材保存命令', async () => { - const invoke = vi.fn(async () => ({ destinationPath: '/tmp/saved' })); + // 图标缩略图也会打 `read_local_project_image_preview`,所以断言认命令、不认调用次数。 + const invoke = vi.fn( + async (command: string, args?: Record) => { + void args; + if (command === 'read_local_project_image_preview') { + return { dataUrl: 'data:image/png;base64,icon' }; + } + return { destinationPath: '/tmp/saved' }; + }, + ); installTauriRuntime({ core: { invoke } }); - renderPane(); + renderPane(fakeMinitool({ artifactReady: true })); - fireEvent.click(screen.getByRole('button', { name: '下载 icon 文件' })); - await waitFor(() => expect(invoke).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole('button', { name: '下载图标文件' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('save_local_project_asset_file', { + input: { + projectPath: PROJECT_PATH, + relativePath: 'assets/icon.png', + destinationPath: '/tmp/icon.png', + }, + }), + ); expect(saveNativeFileDialog).toHaveBeenCalledWith( expect.objectContaining({ defaultPath: 'icon.png' }), ); - expect(invoke).toHaveBeenCalledWith('save_local_project_asset_file', { - input: { - projectPath: PROJECT_PATH, - relativePath: 'assets/icon.png', - destinationPath: '/tmp/icon.png', - }, - }); - fireEvent.click(screen.getByRole('button', { name: '下载代码 zip' })); - await waitFor(() => expect(invoke).toHaveBeenCalledTimes(2)); - expect(invoke).toHaveBeenLastCalledWith('save_local_project_asset_file', { - input: { - projectPath: PROJECT_PATH, - relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, - destinationPath: '/tmp/icon.png', - }, - }); + fireEvent.click(screen.getByRole('button', { name: '下载代码包' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('save_local_project_asset_file', { + input: { + projectPath: PROJECT_PATH, + relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + destinationPath: '/tmp/icon.png', + }, + }), + ); }); - it('没有脚本时导出不可点,适配入口在提示卡片里且只有一颗', () => { - const minitool = fakeMinitool({ hasScript: false }); + it('图标先出缩略图,选择图标走公共素材导入器并把路径写回表单', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + void args; + if (command === 'read_local_project_image_preview') { + return { dataUrl: 'data:image/png;base64,icon' }; + } + return {}; + }, + ); + installTauriRuntime({ core: { invoke } }); + const minitool = fakeMinitool(); renderPane(minitool); - expect(screen.getByRole('button', { name: '导出' })).toHaveProperty( - 'disabled', - true, + // 缩略图:图标路径读成 dataUrl 后直接显示。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_image_preview', + expect.objectContaining({ + projectPath: PROJECT_PATH, + relativePath: 'assets/icon.png', + }), + ), ); - // 按钮行里的「适配」让位给卡片里那颗:同一件事不在同一屏出现两个入口。 - expect(screen.queryByRole('button', { name: '适配' })).toBeNull(); - fireEvent.click( - screen.getByRole('button', { name: '交给 code agent 适配' }), + expect(screen.getByAltText('图标预览')).toHaveProperty( + 'src', + 'data:image/png;base64,icon', ); - expect(minitool.adapt).toHaveBeenCalledTimes(1); - // 没有刷新按钮:脚本判据由自动刷新重读,用户不用核对同步状态。 + // 「选择图标」开的是公共 AssetImporter。 + fireEvent.click(screen.getByRole('button', { name: '选择图标' })); + await screen.findByRole('dialog', { name: '选择图标' }); + + // 导入回调把新图的项目内相对路径写回表单字段。 + fireEvent.click(screen.getByRole('button', { name: '替身:导入一张图' })); + expect(minitool.updateField).toHaveBeenCalledWith( + 'iconPath', + 'assets/uploads/picked.png', + ); + }); + + it('图标读不到就退回占位符,不拿假图糊弄', async () => { + const invoke = vi.fn(async () => { + throw new Error('只能预览已登记资源或已完成任务的图片产物'); + }); + installTauriRuntime({ core: { invoke } }); + renderPane(); + + // 宿主只放行已登记资源:手填一条没登记的路径时预览读不到,占位符要说清楚而不是显示坏图。 + await waitFor(() => + expect(screen.getByRole('img', { name: '图标读不到' })).toBeTruthy(), + ); + expect(screen.queryByAltText('图标预览')).toBeNull(); + }); + + it('「打包」永远可点:没有脚本也交给 hook 去复算;适配入口只在弹窗里', () => { + // 没配置好导出时会先被弹窗拦住;收起它之后「打包」不该还是灰的——点它现算一次,没有脚本就 + // 把弹窗连同「让陶泥儿帮你调通」一起递回来。 + const minitool = fakeMinitool({ + hasScript: false, + adaptPromptDismissed: true, + }); + renderPane(minitool); + + const pack = screen.getByRole('button', { name: '打包' }); + expect(pack).toHaveProperty('disabled', false); + fireEvent.click(pack); + expect(minitool.packageAll).toHaveBeenCalledTimes(1); + + // 适配入口只在弹窗里:面板上没有第二颗常驻的「适配」按钮,也没有刷新按钮。 + expect(screen.queryByRole('button', { name: '适配' })).toBeNull(); expect(screen.queryByRole('button', { name: '刷新' })).toBeNull(); expect(screen.queryByText(/自动保存|保存中/u)).toBeNull(); cleanup(); - const withScript = fakeMinitool(); - renderPane(withScript); - fireEvent.click(screen.getByRole('button', { name: '导出' })); - expect(withScript.runExport).toHaveBeenCalledTimes(1); - // 有脚本时卡片不再出现,适配入口回到按钮行;它仍然能入队,不是个摆设。 - expect( - screen.queryByRole('button', { name: '交给 code agent 适配' }), - ).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '适配' })); - expect(withScript.adapt).toHaveBeenCalledTimes(1); + // 弹窗在场时两个选择都显式给出。 + const prompted = fakeMinitool({ hasScript: false }); + renderPane(prompted); + fireEvent.click(screen.getByRole('button', { name: '让陶泥儿帮我调通' })); + expect(prompted.adapt).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '我自己来' })); + expect(prompted.dismissAdaptPrompt).toHaveBeenCalledTimes(1); }); - it('注册表还没读回来时不下「还没适配」的结论', () => { - renderPane(fakeMinitool({ form: null, loading: true, hasScript: false })); + it('只有正在打包时才变灰,其余时候都能点', () => { + const idle = fakeMinitool({ hasScript: false }); + renderPane(idle); + expect(screen.getByRole('button', { name: '打包' })).toHaveProperty( + 'disabled', + false, + ); + cleanup(); - // 读取中给一行状态,但不下「还没适配」的结论、也不摆那张自带入口的卡片。 - screen.getByText('正在读取导出注册表…'); - expect(screen.queryByText(missingScriptTip())).toBeNull(); - expect( - screen.queryByRole('button', { name: '交给 code agent 适配' }), - ).toBeNull(); + const busy = fakeMinitool({ hasScript: true, running: true }); + renderPane(busy); + expect(screen.getByRole('button', { name: '打包中…' })).toHaveProperty( + 'disabled', + true, + ); }); - it('读失败时保留按钮行里的适配入口,不摆未适配卡片', () => { + it('「帮我填」交给 hook:这颗按钮只负责把动作递出去', () => { + const minitool = fakeMinitool(); + renderPane(minitool); + + fireEvent.click(screen.getByRole('button', { name: '帮我填' })); + + expect(minitool.fillForm).toHaveBeenCalledTimes(1); + }); + + it('阻断弹窗选「我自己来」只收起提示,不替用户叫 agent', () => { + const minitool = fakeMinitool({ hasScript: false }); + renderPane(minitool); + + fireEvent.click(screen.getByRole('button', { name: '我自己来' })); + + expect(minitool.dismissAdaptPrompt).toHaveBeenCalledTimes(1); + expect(minitool.adapt).not.toHaveBeenCalled(); + }); + + it('提示收起后不再拦人,也不留常驻适配按钮', () => { const minitool = fakeMinitool({ - form: null, - loading: false, hasScript: false, + adaptPromptDismissed: true, }); renderPane(minitool); - expect(screen.queryByText(missingScriptTip())).toBeNull(); - // 读失败不等于入口失效:那颗按钮还在,点下去照样把任务交给 agent。 - fireEvent.click(screen.getByRole('button', { name: '适配' })); - expect(minitool.adapt).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: '我自己来' })).toBeNull(); + expect( + screen.queryByRole('button', { name: '让陶泥儿帮我调通' }), + ).toBeNull(); + expect(screen.queryByRole('button', { name: '适配' })).toBeNull(); + // 收起提示不等于把路堵死:「打包」还在,点它会现算一次并把适配出口重新递出来。 + expect(screen.getByRole('button', { name: '打包' })).toHaveProperty( + 'disabled', + false, + ); + }); + + it('注册表还没读回来时不下「还没适配」的结论', () => { + renderPane( + fakeMinitool({ + form: null, + loading: true, + readSettled: false, + hasScript: false, + }), + ); + + // 读取中给一行状态,但不下「还没适配」的结论、也不弹那张自带入口的提示。 + screen.getByText('正在读取导出信息…'); + expect(screen.queryByText(notConfiguredTip())).toBeNull(); + expect( + screen.queryByRole('button', { name: '让陶泥儿帮我调通' }), + ).toBeNull(); + }); + + it('读失败时不弹未适配提示', () => { + renderPane(fakeMinitool({ form: null, loading: false, hasScript: false })); + + expect(screen.queryByText(notConfiguredTip())).toBeNull(); + expect( + screen.queryByRole('button', { name: '让陶泥儿帮我调通' }), + ).toBeNull(); + }); + + it('读落定之后失败就不再假装还在读:上面交给失败卡片,不重复一句话', () => { + renderPane( + fakeMinitool({ + form: null, + readSettled: true, + failure: { type: 'registryMalformed', cause: 'bad json' }, + }), + ); + + expect(screen.queryByText('正在读取导出信息…')).toBeNull(); + expect(screen.getByRole('alert')).toBeTruthy(); + }); + + it('认不出形状的拒绝给一句实话,不装死在「正在读取」上', () => { + renderPane(fakeMinitool({ form: null, readSettled: true, failure: null })); + + expect(screen.queryByText('正在读取导出信息…')).toBeNull(); + screen.getByText('没能读到导出信息,正在自动重试…'); }); it('冲突面板逐字段二选一,按选择保存的合并结果交回上层', () => { @@ -279,8 +491,12 @@ describe('面板左半', () => { renderPane(minitool); // 只有真的不一样的那个字段出现在冲突面板里。 - expect(screen.queryByRole('radio', { name: '名称:文件里的' })).toBeNull(); - fireEvent.click(screen.getByRole('radio', { name: '简介:文件里的' })); + expect( + screen.queryByRole('radio', { name: '小工具名称:项目里的' }), + ).toBeNull(); + fireEvent.click( + screen.getByRole('radio', { name: '一句话介绍:项目里的' }), + ); fireEvent.click(screen.getByRole('button', { name: '按选择保存' })); expect(minitool.resolveConflict).toHaveBeenCalledWith({ @@ -289,66 +505,123 @@ describe('面板左半', () => { }); }); - it('失败卡片按变体给出「让 code agent 适配 / 修」或「重试」', () => { - // 还没适配:入队的必须是首次适配指令,修复指令会引用一份还不存在的适配说明。 + it('失败卡片按变体给出「让陶泥儿调通」或「让陶泥儿来修」', () => { + // 还没配置好导出:入队的必须是首次适配指令,修复指令会引用一份还不存在的适配说明。 const neverAdapted = fakeMinitool({ hasScript: false, - failure: failureOf({ + failure: { type: 'buildScriptMissing', script: XHS_MINITOOL_SCRIPT_NAME, searched: ['package.json', 'game/package.json'], - }), + }, }); renderPane(neverAdapted); - // 现场照载荷原文写出来(宿主找过哪些 package.json 由载荷决定,前端不自己编)。 - screen.getByText('宿主找过:package.json、game/package.json'); - expect( - screen.queryByRole('button', { name: '让 code agent 修' }), - ).toBeNull(); - fireEvent.click( - screen.getByRole('button', { name: '交给 code agent 适配' }), + // 卡片说用户看得懂的话,不把脚本名与宿主找过哪些目录抖出来。 + screen.getByText( + '这个项目还没有配置好导出。先让陶泥儿帮你调通一次,之后就能直接打包了。', ); + expect(screen.queryByText(/build:xhs-minitool/u)).toBeNull(); + expect(screen.queryByRole('button', { name: '让陶泥儿来修' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '让陶泥儿帮我调通' })); expect(neverAdapted.adapt).toHaveBeenCalledTimes(1); expect(neverAdapted.repair).not.toHaveBeenCalled(); - // 适配过但没跑通:入队修复指令,带上这次的现场。 + // 配置过但这次没跑通:入队修复指令,带上这次的现场。 cleanup(); const broken = fakeMinitool({ - failure: failureOf({ + failure: { type: 'commandFailed', exitCode: 1, outputTail: 'vite build failed', omittedCharacters: 0, - }), + }, }); renderPane(broken); - fireEvent.click(screen.getByRole('button', { name: '让 code agent 修' })); + fireEvent.click(screen.getByRole('button', { name: '让陶泥儿来修' })); expect(broken.repair).toHaveBeenCalledTimes(1); expect(broken.adapt).not.toHaveBeenCalled(); + }); - // 宿主自身没跑起来:只给重试,不把 agent 拉进来瞎猜。 - cleanup(); - const unavailable = fakeMinitool({ - failure: failureOf({ - type: 'exportUnavailable', - cause: '项目权限策略拒绝执行:command.exec', - }), - }); - renderPane(unavailable); + it('只能人来改的失败,卡片不给任何「让陶泥儿」入口', () => { + // command.exec 被项目权限策略拉黑,改策略的是用户,agent 受同一条策略约束; + // 宿主没跑起来更是只能重试。`saveConflict` 到不了这里——它有冲突面板。 + const humanOnlyFailures: XhsMinitoolCardFailure[] = [ + { type: 'commandDenied', commandId: 'command.exec' }, + { type: 'exportUnavailable', cause: '项目根不可用:找不到目录' }, + ]; + for (const failure of humanOnlyFailures) { + cleanup(); + const minitool = fakeMinitool({ failure }); + renderPane(minitool); - fireEvent.click(screen.getByRole('button', { name: '重试' })); - expect(unavailable.runExport).toHaveBeenCalledTimes(1); - expect(unavailable.repair).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: '让陶泥儿来修' })).toBeNull(); + expect( + screen.queryByRole('button', { name: '让陶泥儿帮我调通' }), + ).toBeNull(); + // 用户能做的只有重试;现场留着,等用户去界面上改。 + fireEvent.click(screen.getByRole('button', { name: '重试' })); + expect(minitool.runExport).toHaveBeenCalledTimes(1); + expect(minitool.repair).not.toHaveBeenCalled(); + expect(minitool.adapt).not.toHaveBeenCalled(); + } }); }); -function failureOf( - error: XhsMinitoolFailureState['error'], -): XhsMinitoolFailureState { - return { notice: describeXhsMinitoolFailure(error), error }; -} +describe('命令失败各有一条表', () => { + /** 三条命令各拒一次:守卫只认领自己那条命令的载体,不认领别的。 */ + function failingInvoke(): TauriInvoke { + return (async (command: string) => { + if (command === 'read_xhs_minitool_export') { + throw { type: 'registryMalformed', cause: 'JSON 解析失败' }; + } + if (command === 'save_xhs_minitool_export_form') { + throw { type: 'formInvalid', field: 'name', reason: '名称不能为空' }; + } + throw { + type: 'commandFailed', + exitCode: 1, + outputTail: '', + omittedCharacters: 0, + }; + }) as TauriInvoke; + } + + it('读命令的失败只被读守卫认领', async () => { + const error = await readXhsMinitoolExport( + failingInvoke(), + PROJECT_PATH, + ).catch((thrown: unknown) => thrown); + + expect(asXhsMinitoolReadFailure(error)?.type).toBe('registryMalformed'); + expect(asXhsMinitoolSaveFailure(error)).toBeNull(); + expect(asXhsMinitoolBuildFailure(error)).toBeNull(); + }); + + it('保存命令的失败只被保存守卫认领', async () => { + const error = await saveXhsMinitoolExportForm(failingInvoke(), { + projectPath: PROJECT_PATH, + form: FORM, + baseHash: CONTENT_HASH, + }).catch((thrown: unknown) => thrown); + + expect(asXhsMinitoolSaveFailure(error)?.type).toBe('formInvalid'); + expect(asXhsMinitoolReadFailure(error)).toBeNull(); + expect(asXhsMinitoolBuildFailure(error)).toBeNull(); + }); + + it('构建命令的失败只被构建守卫认领', async () => { + const error = await runXhsMinitoolExportBuild( + failingInvoke(), + PROJECT_PATH, + ).catch((thrown: unknown) => thrown); + + expect(asXhsMinitoolBuildFailure(error)?.type).toBe('commandFailed'); + expect(asXhsMinitoolReadFailure(error)).toBeNull(); + expect(asXhsMinitoolSaveFailure(error)).toBeNull(); + }); +}); describe('状态 hook', () => { it('读到内容后改字段停手会自动保存,并把读到的指纹当 baseHash 带回宿主', async () => { @@ -464,15 +737,22 @@ describe('状态 hook', () => { expect(result.current.form).toEqual(FORM); }); - it('导出把打包脚本的输出尾部一并报给用户', async () => { + it('打包成功才有下载入口,失败会收回去', async () => { + let fail = false; const invoke = vi.fn(async (command: string) => { if (command === 'read_xhs_minitool_export') { return { form: FORM, hasScript: true }; } - return { - outputTail: `zip 已生成:${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}(3 个条目,0.42 MiB)`, - omittedCharacters: 0, - }; + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + if (fail) { + throw { + type: 'commandFailed', + exitCode: 1, + outputTail: 'vite build failed', + omittedCharacters: 0, + }; + } + return { outputTail: 'zip 已生成', omittedCharacters: 0 }; }); installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => @@ -483,19 +763,20 @@ describe('状态 hook', () => { }), ); await waitFor(() => expect(result.current.form).toEqual(FORM)); + // 宿主不报告磁盘上有没有产物,所以没打过之前不给下载入口。 + expect(result.current.artifactReady).toBe(false); await act(async () => { - await result.current.runExport(); + await result.current.packageAll(); }); + expect(result.current.artifactReady).toBe(true); - expect(invoke).toHaveBeenCalledWith('run_xhs_minitool_export_build', { - projectPath: PROJECT_PATH, + // 再打一次失败:上次成功的那份可能已被覆盖或删掉,下载入口要收回去。 + fail = true; + await act(async () => { + await result.current.packageAll(); }); - expect(result.current.notice).toContain( - XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, - ); - expect(result.current.notice).toContain('3 个条目'); - expect(result.current.running).toBe(false); + expect(result.current.artifactReady).toBe(false); }); it('输出被宿主截断时,说明那句话由前端拼', async () => { @@ -522,10 +803,162 @@ describe('状态 hook', () => { // 宿主只回事实(原文 + 省略量),提示里那句「已省略」是这里拼的。 expect(result.current.notice).toBe( - `已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\n(日志过长,已省略前 4096 个字符)\nzip 已生成`, + '代码包已重新生成,点「下载」保存到本地。\n(内容较长,前面省略了 4096 个字符)\nzip 已生成', ); }); + it('打包先把没保存的输入落盘,成功后再跑构建', async () => { + const calls: string[] = []; + const invoke = vi.fn(async (command: string) => { + calls.push(command); + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + if (command === 'save_xhs_minitool_export_form') return 'hash-2'; + return { outputTail: 'zip 已生成', omittedCharacters: 0 }; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + await act(async () => { + result.current.updateField('name', '新名字'); + }); + await act(async () => { + await result.current.packageAll(); + }); + + // 校验(保存)必须在构建之前:不能拿一份没落盘的表单去打包。 + expect(calls).toContain('save_xhs_minitool_export_form'); + expect(calls).toContain('run_xhs_minitool_export_build'); + expect(calls.indexOf('save_xhs_minitool_export_form')).toBeLessThan( + calls.indexOf('run_xhs_minitool_export_build'), + ); + expect(invoke).toHaveBeenCalledWith('save_xhs_minitool_export_form', { + projectPath: PROJECT_PATH, + form: { ...FORM, name: '新名字' }, + baseHash: CONTENT_HASH, + }); + expect(result.current.running).toBe(false); + }); + + it('没有脚本时点「打包」:当场复算一次、不跑构建,并把适配出口重新递出来', async () => { + const calls: string[] = []; + const invoke = vi.fn(async (command: string) => { + calls.push(command); + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: false }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + return { outputTail: 'zip 已生成', omittedCharacters: 0 }; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + expect(result.current.hasScript).toBe(false); + + // 用户先选过「我自己来」,弹窗已经被收起。 + await act(async () => { + result.current.dismissAdaptPrompt(); + }); + expect(result.current.adaptPromptDismissed).toBe(true); + + calls.length = 0; + await act(async () => { + await result.current.packageAll(); + }); + + // 点下去先现算一次「有没有脚本」,不拿上一个轮询周期的结论当家。 + expect(calls).toContain('read_xhs_minitool_export'); + expect(calls).not.toContain('run_xhs_minitool_export_build'); + // 还没有脚本就把「让陶泥儿帮你调通」重新递出来,而不是报个错把人晾在原地。 + expect(result.current.adaptPromptDismissed).toBe(false); + expect(result.current.notice).toBe( + '项目还没配置好导出,先让陶泥儿帮你调通,之后就能直接打包了', + ); + expect(result.current.running).toBe(false); + }); + + it('脚本是点「打包」那一刻才有的:复算读到脚本就直接构建', async () => { + // 上一轮轮询读到的是「没有脚本」,之后 agent 把脚本写进了项目。 + let hasScript = false; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + return { outputTail: 'zip 已生成', omittedCharacters: 0 }; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + expect(result.current.hasScript).toBe(false); + + hasScript = true; + await act(async () => { + await result.current.packageAll(); + }); + + expect(invoke).toHaveBeenCalledWith('run_xhs_minitool_export_build', { + projectPath: PROJECT_PATH, + }); + expect(result.current.artifactReady).toBe(true); + }); + + it('打包时表单校验不过就停在表单上,不跑构建', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + if (command === 'save_xhs_minitool_export_form') { + throw { type: 'formInvalid', field: 'name', reason: '名称太长' }; + } + return { outputTail: 'zip 已生成', omittedCharacters: 0 }; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + await act(async () => { + result.current.updateField('name', '新名字'); + }); + await act(async () => { + await result.current.packageAll(); + }); + + expect(result.current.fieldError?.field).toBe('name'); + expect(invoke).not.toHaveBeenCalledWith('run_xhs_minitool_export_build', { + projectPath: PROJECT_PATH, + }); + expect(result.current.running).toBe(false); + }); + it('认不出形状的拒绝原样抛出进错误池,不当成业务提示糊给用户', async () => { // Tauri 传输失败 / 命令 panic / 参数序列化失败都是真 `Error`:它不是结构化拒绝, // 面板不许把它的 message 当成业务失败的解释,只把它交回错误池。 @@ -578,17 +1011,97 @@ describe('状态 hook', () => { await act(async () => { await expect(result.current.runExport()).rejects.toBeInstanceOf(Error); }); - expect(result.current.failure?.notice.message).toBe( - '宿主这次没能执行导出。', - ); - expect(result.current.failure?.notice.detail).toBe( - '项目根不可用:找不到目录', - ); + // 面板留下的就是宿主那个原始错误,用户那句话是渲染时从变体推的。 + expect(result.current.failure).toEqual({ + type: 'exportUnavailable', + cause: '项目根不可用:找不到目录', + }); await act(async () => { await expect(result.current.runExport()).resolves.toBeUndefined(); }); }); + it('帮我填:先把没保存的输入落盘,再把改注册表这件事交给同一个会话', async () => { + const order: string[] = []; + const invoke = vi.fn(async (command: string) => { + order.push(`invoke:${command}`); + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + if (command === 'save_xhs_minitool_export_form') return 'hash-2'; + return { outputTail: '', omittedCharacters: 0 }; + }); + installTauriRuntime({ core: { invoke } }); + const submitTurn = vi.fn(async () => { + order.push('submit'); + return true; + }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn, + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + order.length = 0; + + await act(async () => { + result.current.updateField('name', '新名字'); + }); + await act(async () => { + await result.current.fillForm(); + }); + + // agent 是照盘上那份文件改的:没落盘的输入它看不见,而且 `dirty` 不清零时重读不覆盖表单。 + expect(invoke).toHaveBeenCalledWith('save_xhs_minitool_export_form', { + projectPath: PROJECT_PATH, + form: { ...FORM, name: '新名字' }, + baseHash: CONTENT_HASH, + }); + expect(order.indexOf('invoke:save_xhs_minitool_export_form')).toBeLessThan( + order.indexOf('submit'), + ); + expect(submitTurn).toHaveBeenCalledTimes(1); + const [content] = submitTurn.mock.calls[0] as unknown as [ + Array<{ type: string; text: string }>, + ]; + expect(content).toHaveLength(1); + expect(content[0]?.type).toBe('input_text'); + }); + + it('表单不合法也照样交给 agent:它正是来收拾这份表单的', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + throw { type: 'formInvalid', field: 'name', reason: '名称太长' }; + }); + installTauriRuntime({ core: { invoke } }); + const submitTurn = vi.fn(async () => true); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn, + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + await act(async () => { + result.current.updateField('name', '新名字'); + }); + await act(async () => { + await result.current.fillForm(); + }); + + // 保存被宿主拒了,红字留在表单上,但这条消息已经发出去了——拦住它只剩用户自己改。 + expect(result.current.fieldError?.field).toBe('name'); + expect(submitTurn).toHaveBeenCalledTimes(1); + }); + it('适配把指令当成一条用户消息交给同一个会话', async () => { const submitTurn = vi.fn(async () => true); installTauriRuntime({ @@ -617,7 +1130,89 @@ describe('状态 hook', () => { ]; expect(content).toHaveLength(1); expect(content[0]?.type).toBe('input_text'); - expect(content[0]?.text).toContain(XHS_MINITOOL_SCRIPT_NAME); + }); + + it('失败修复把原始错误交给同一个会话', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + throw { + type: 'commandFailed', + exitCode: 1, + outputTail: 'vite build failed', + omittedCharacters: 0, + }; + }); + installTauriRuntime({ core: { invoke } }); + const submitTurn = vi.fn(async () => true); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn, + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + await act(async () => { + await result.current.runExport(); + }); + expect(result.current.failure).toEqual({ + type: 'commandFailed', + exitCode: 1, + outputTail: 'vite build failed', + omittedCharacters: 0, + }); + + await act(async () => { + result.current.repair(); + }); + + expect(submitTurn).toHaveBeenCalledTimes(1); + const [content] = submitTurn.mock.calls[0] as unknown as [ + Array<{ type: string; text: string }>, + ]; + expect(content).toHaveLength(1); + expect(content[0]?.type).toBe('input_text'); + }); + + it('只能人来改的失败,repair 不发指令也不清现场', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + throw { type: 'saveConflict', current: FORM, contentHash: 'hash-2' }; + }); + installTauriRuntime({ core: { invoke } }); + const submitTurn = vi.fn(async () => true); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn, + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + await act(async () => { + await result.current.runExport(); + }); + expect(result.current.failure).toEqual({ + type: 'saveConflict', + current: FORM, + contentHash: 'hash-2', + }); + + await act(async () => { + result.current.repair(); + }); + + // 冲突是人在冲突面板上逐字段选的事,不许把 agent 叫进来;现场留着等用户处理。 + expect(submitTurn).not.toHaveBeenCalled(); + expect(result.current.failure).not.toBeNull(); }); }); @@ -638,12 +1233,18 @@ function fakeMinitool( failure: null, notice: '', loading: false, + readSettled: true, running: false, + artifactReady: false, + adaptPromptDismissed: false, updateField: vi.fn(), resolveConflict: vi.fn(), runExport: vi.fn(async () => undefined), + packageAll: vi.fn(async () => undefined), adapt: vi.fn(), repair: vi.fn(), + fillForm: vi.fn(async () => undefined), + dismissAdaptPrompt: vi.fn(), ...overrides, }; }