From f8517abaaa2776dd211418eec0e80d6047254620 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 16:02:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=9A=84=E5=89=8D=E7=AB=AF=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx:十例覆盖指令正文(三条硬契约、示例脚本落点、修复现场)、失败变体到人话与动作的映射、字段复制、icon 与 zip 的下载链路、三颗按钮的职责、冲突逐字段二选一、失败卡片下一步,以及状态 hook 的自动保存(baseHash 带回宿主)与「适配把指令当一条用户消息入队」。 --- .../tests/xhsMinitoolExport.test.tsx | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx new file mode 100644 index 000000000..910a8b4ea --- /dev/null +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -0,0 +1,338 @@ +// @vitest-environment jsdom +/** + * 小红书小工具导出:前端这一层的契约。 + * + * 三个切面各钉一块: + * - 交给 code agent 的指令正文(纯字符串组装,含三条硬契约与要拷的示例脚本); + * - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步); + * - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。 + */ +import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor, +} 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 { useXhsMinitoolExport } from '../src/view/project-development/export/state/useXhsMinitoolExport'; +import { describeXhsMinitoolFailure } from '../src/view/project-development/export/state/xhsMinitoolFailure'; +import { + buildXhsMinitoolAdaptationInstruction, + buildXhsMinitoolRepairInstruction, +} from '../src/view/project-development/export/state/xhsMinitoolInstruction'; +import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; + +vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() })); + +const PROJECT_PATH = '/tmp/export-panel-project'; +const CONTENT_HASH = 'hash-1'; + +const FORM = { + name: '小工具', + introduction: '一句话简介', + iconPath: 'assets/icon.png', +}; + +beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn(async () => undefined) }, + }); + vi.mocked(saveNativeFileDialog).mockResolvedValue('/tmp/icon.png'); +}); + +afterEach(() => { + cleanup(); + resetTauriRuntime(); + vi.clearAllMocks(); +}); + +describe('交给 code agent 的指令', () => { + it('适配指令写清三条硬契约与要显式拷贝的示例脚本', () => { + const instruction = buildXhsMinitoolAdaptationInstruction(PROJECT_PATH); + + expect(instruction).toContain(PROJECT_PATH); + expect(instruction).toContain('build:xhs-minitool'); + expect(instruction).toContain('.export/xhs-minitool.zip'); + expect(instruction).toContain('.export/xhs-minitool.json'); + expect(instruction).toContain('"iconPath":""'); + expect(instruction).toContain('vite-export-xhs-minitool'); + // 示例脚本必须被显式要求拷贝,只"参考 skill"会退化成 agent 自由发挥。 + expect(instruction).toContain('pack.mjs → .export/xhs-minitool.pack.mjs'); + expect(instruction).toContain( + 'validate.mjs → .export/xhs-minitool.validate.mjs', + ); + expect(instruction).toContain( + 'vite.config.xhs-minitool.mjs → .export/xhs-minitool.vite.config.mjs', + ); + // pack.mjs 会删 --out-dir 里的非白名单文件,这条坑必须写进指令正文。 + expect(instruction).toContain('--out-dir'); + }); + + it('修复指令带上宿主的失败现场', () => { + const instruction = buildXhsMinitoolRepairInstruction(PROJECT_PATH, { + type: 'artifactMissing', + path: '.export/xhs-minitool.zip', + }); + + expect(instruction).toContain(PROJECT_PATH); + expect(instruction).toContain('.export/xhs-minitool.zip 不在'); + expect(instruction).toContain('build:xhs-minitool'); + }); + + it('每个失败变体都有一句人话与下一步', () => { + expect( + describeXhsMinitoolFailure({ + type: 'buildScriptMissing', + script: 'build:xhs-minitool', + searched: ['package.json', 'game/package.json'], + }), + ).toEqual({ + message: + '项目里还没有 build:xhs-minitool 脚本——这就是「还没适配」的判据。', + detail: '宿主找过:package.json、game/package.json', + action: 'adapt', + }); + expect( + describeXhsMinitoolFailure({ + type: 'commandFailed', + exitCode: 1, + outputTail: 'vite build failed', + }), + ).toMatchObject({ action: 'adapt', detail: 'vite build failed' }); + expect( + describeXhsMinitoolFailure({ + type: 'exportUnavailable', + cause: '项目权限策略拒绝执行:command.exec', + }).action, + ).toBe('retry'); + expect( + describeXhsMinitoolFailure({ + type: 'formInvalid', + field: 'iconPath', + reason: 'icon 只支持 png、jpg、jpeg,当前是 .webp', + }).message, + ).toContain('icon 文件'); + }); +}); + +describe('面板左半', () => { + it('复制按钮把字段值写进剪贴板', async () => { + renderPane(); + + fireEvent.click(screen.getByRole('button', { name: '复制名称' })); + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('小工具'), + ); + }); + + it('icon 与 zip 的下载都走原生保存对话框与素材保存命令', async () => { + const invoke = vi.fn(async () => ({ destinationPath: '/tmp/saved' })); + installTauriRuntime({ core: { invoke } }); + renderPane(); + + fireEvent.click(screen.getByRole('button', { name: '下载 icon 文件' })); + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(1)); + 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: '.export/xhs-minitool.zip', + destinationPath: '/tmp/icon.png', + }, + }); + }); + + it('三颗按钮各管一件事,没有脚本时导出不可点', () => { + const minitool = fakeMinitool({ hasScript: false }); + renderPane(minitool); + + expect(screen.getByRole('button', { name: '导出' })).toHaveProperty( + 'disabled', + true, + ); + fireEvent.click(screen.getByRole('button', { name: '适配' })); + expect(minitool.adapt).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + expect(minitool.load).toHaveBeenCalledTimes(1); + + cleanup(); + const withScript = fakeMinitool(); + renderPane(withScript); + fireEvent.click(screen.getByRole('button', { name: '导出' })); + expect(withScript.runExport).toHaveBeenCalledTimes(1); + }); + + it('冲突面板逐字段二选一,按选择保存的合并结果交回上层', () => { + const conflict: XhsMinitoolExportState['conflict'] = { + current: { ...FORM, introduction: 'agent 写的简介' }, + contentHash: 'hash-2', + }; + const minitool = fakeMinitool({ conflict }); + renderPane(minitool); + + // 只有真的不一样的那个字段出现在冲突面板里。 + expect(screen.queryByRole('radio', { name: '名称:文件里的' })).toBeNull(); + fireEvent.click(screen.getByRole('radio', { name: '简介:文件里的' })); + fireEvent.click(screen.getByRole('button', { name: '按选择保存' })); + + expect(minitool.resolveConflict).toHaveBeenCalledWith({ + ...FORM, + introduction: 'agent 写的简介', + }); + }); + + it('失败卡片按变体给出「让 code agent 修」或「重试」', () => { + const failure: XhsMinitoolFailureState = { + notice: { + message: + '项目里还没有 build:xhs-minitool 脚本——这就是「还没适配」的判据。', + detail: '宿主找过:package.json、game/package.json', + action: 'adapt', + }, + error: { + type: 'buildScriptMissing', + script: 'build:xhs-minitool', + searched: ['package.json', 'game/package.json'], + }, + }; + const minitool = fakeMinitool({ hasScript: false, failure }); + renderPane(minitool); + + expect( + screen.getByText('宿主找过:package.json、game/package.json'), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '让 code agent 修' })); + expect(minitool.repair).toHaveBeenCalledTimes(1); + }); +}); + +describe('状态 hook', () => { + it('读到现场后,改字段停手会自动保存并把 baseHash 带回宿主', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { + form: FORM, + hasScript: true, + contentHash: CONTENT_HASH, + }; + } + return { form: FORM, hasScript: true, contentHash: 'hash-2' }; + }); + installTauriRuntime({ core: { invoke } }); + + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + expect(invoke).toHaveBeenCalledWith('read_xhs_minitool_export', { + projectPath: PROJECT_PATH, + }); + // 先等现场读回来:读到之前 `form` 还是 null,改字段无处可改。 + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + await act(async () => { + result.current.updateField('name', '新名字'); + }); + + await waitFor( + () => + expect(invoke).toHaveBeenCalledWith('save_xhs_minitool_export_form', { + projectPath: PROJECT_PATH, + form: { ...FORM, name: '新名字' }, + baseHash: CONTENT_HASH, + }), + { timeout: 3000 }, + ); + }); + + it('适配把指令当成一条用户消息交给同一个会话', async () => { + const submitTurn = vi.fn(async () => true); + installTauriRuntime({ + core: { + invoke: vi.fn(async () => ({ + form: FORM, + hasScript: false, + contentHash: CONTENT_HASH, + })), + }, + }); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn, + }), + ); + + await act(async () => { + result.current.adapt(); + }); + + 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'); + expect(content[0]?.text).toContain('build:xhs-minitool'); + }); +}); + +function renderPane(minitool: XhsMinitoolExportState = fakeMinitool()) { + return render( + , + ); +} + +function fakeMinitool( + overrides: Partial = {}, +): XhsMinitoolExportState { + return { + form: FORM, + hasScript: true, + contentHash: CONTENT_HASH, + fieldError: null, + conflict: null, + failure: null, + notice: '', + loading: false, + saving: false, + running: false, + dirty: false, + updateField: vi.fn(), + load: vi.fn(async () => undefined), + resolveConflict: vi.fn(), + runExport: vi.fn(async () => undefined), + adapt: vi.fn(), + repair: vi.fn(), + ...overrides, + }; +}